Merge self-hosted/feature/account-charges into feature/Backend-DSC-MFA

This commit is contained in:
Aditya
2026-06-01 12:14:49 +05:30
85 changed files with 16973 additions and 11837 deletions
@@ -23,6 +23,7 @@ public enum PortfolioAccountType {
INVALID(0, "accountType.invalid"), // INVALID(0, "accountType.invalid"), //
LOAN(1, "accountType.loan"), // LOAN(1, "accountType.loan"), //
SAVINGS(2, "accountType.savings"), // SAVINGS(2, "accountType.savings"), //
CURRENT(3, "accountType.current"), //
; ;
private final Integer value; private final Integer value;
@@ -52,6 +53,9 @@ public enum PortfolioAccountType {
case 2: case 2:
enumType = SAVINGS; enumType = SAVINGS;
break; break;
case 3:
enumType = CURRENT;
break;
default: default:
enumType = INVALID; enumType = INVALID;
} }
@@ -68,4 +72,8 @@ public enum PortfolioAccountType {
public boolean isLoanAccount() { public boolean isLoanAccount() {
return this.equals(LOAN); return this.equals(LOAN);
} }
public boolean isCurrentAccount() {
return this.equals(CURRENT);
}
} }
@@ -120,6 +120,8 @@ public final class DepositsApiConstants {
public static final String principalAmountParamName = "principalAmount"; public static final String principalAmountParamName = "principalAmount";
public static final String annualInterestRateParamName = "annualInterestRate"; public static final String annualInterestRateParamName = "annualInterestRate";
public static final String interestPostingPeriodInMonthsParamName = "interestPostingPeriodInMonths"; public static final String interestPostingPeriodInMonthsParamName = "interestPostingPeriodInMonths";
public static final String isSeniorCitizenParamName = "isSeniorCitizen";
public static final String seniorCitizenInterestRateParamName = "seniorCitizenInterestRate";
public static final String tenureInMonthsParamName = "tenureInMonths"; public static final String tenureInMonthsParamName = "tenureInMonths";
public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths"; public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths";
@@ -544,6 +546,8 @@ public final class DepositsApiConstants {
fixedDepositRequestData.add(SavingsApiConstants.jointHoldersParamName); fixedDepositRequestData.add(SavingsApiConstants.jointHoldersParamName);
fixedDepositRequestData.add(SavingsApiConstants.autoDebitFacilityParamName); fixedDepositRequestData.add(SavingsApiConstants.autoDebitFacilityParamName);
fixedDepositRequestData.add(SavingsApiConstants.nominationFacilityParamName); fixedDepositRequestData.add(SavingsApiConstants.nominationFacilityParamName);
fixedDepositRequestData.add(isSeniorCitizenParamName);
fixedDepositRequestData.add(seniorCitizenInterestRateParamName);
return fixedDepositRequestData; return fixedDepositRequestData;
} }
@@ -612,6 +616,8 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add(SavingsApiConstants.balanceSwapOptionParamName); recurringDepositRequestData.add(SavingsApiConstants.balanceSwapOptionParamName);
recurringDepositRequestData.add(SavingsApiConstants.msmeParamName); recurringDepositRequestData.add(SavingsApiConstants.msmeParamName);
recurringDepositRequestData.add(SavingsApiConstants.autoDebitFacilityParamName); recurringDepositRequestData.add(SavingsApiConstants.autoDebitFacilityParamName);
recurringDepositRequestData.add(isSeniorCitizenParamName);
recurringDepositRequestData.add(seniorCitizenInterestRateParamName);
return recurringDepositRequestData; return recurringDepositRequestData;
} }
@@ -600,6 +600,27 @@ public class AccountingProcessorHelper {
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount); createCreditJournalEntryForSavings(office, currencyCode, creditAccount, savingsId, transactionId, transactionDate, amount);
} }
/**
* Creates a direct transfer journal entry between two savings-type accounts (savings or current),
* each with their own product GL mapping and entity ID so the correct account number appears per entry.
*
* @param debitProductId product ID whose SAVINGS_CONTROL GL is debited
* @param debitEntityId entity (account) ID shown on the debit entry
* @param creditProductId product ID whose SAVINGS_CONTROL GL is credited
* @param creditEntityId entity (account) ID shown on the credit entry
*/
public void createDirectTransferJournalEntriesForSavings(final Office office, final String currencyCode,
final Long debitProductId, final Long debitEntityId,
final Long creditProductId, final Long creditEntityId,
final String transactionId, final LocalDate transactionDate, final BigDecimal amount) {
final GLAccount debitAccount = getLinkedGLAccountForSavingsProduct(debitProductId,
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null);
final GLAccount creditAccount = getLinkedGLAccountForSavingsProduct(creditProductId,
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), null);
createDebitJournalEntryForSavings(office, currencyCode, debitAccount, debitEntityId, transactionId, transactionDate, amount);
createCreditJournalEntryForSavings(office, currencyCode, creditAccount, creditEntityId, transactionId, transactionDate, amount);
}
/** /**
* Convenience method that creates a pair of related Debits and Credits for Cash Based accounting. * Convenience method that creates a pair of related Debits and Credits for Cash Based accounting.
* *
@@ -106,8 +106,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
.append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ") .append(" curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
.append(" eao.external_id as externalAssetOwner, ") .append(" eao.external_id as externalAssetOwner, ")
.append(" CASE ") .append(" CASE ")
.append(" WHEN journalEntry.entity_type_enum = 2 AND journalEntry.transaction_id LIKE 'CA%' THEN ca.account_no ") .append(" WHEN journalEntry.entity_type_enum = 2 THEN COALESCE(ca.account_no, sa.account_no) ")
.append(" WHEN journalEntry.entity_type_enum = 2 THEN sa.account_no ")
.append(" ELSE NULL ") .append(" ELSE NULL ")
.append(" END as savingsAccountNo "); .append(" END as savingsAccountNo ");
if (associationParametersData.isRunningBalanceRequired()) { if (associationParametersData.isRunningBalanceRequired()) {
@@ -130,8 +129,8 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
.append(" join m_currency curr on curr.code = journalEntry.currency_code ") .append(" join m_currency curr on curr.code = journalEntry.currency_code ")
.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_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_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 AND journalEntry.transaction_id NOT LIKE 'CA%' ") .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 AND journalEntry.transaction_id LIKE 'CA%' "); .append(" left join m_current_account ca on ca.id = journalEntry.entity_id AND journalEntry.entity_type_enum = 2 ");
if (associationParametersData.isTransactionDetailsRequired()) { if (associationParametersData.isTransactionDetailsRequired()) {
sb.append(" left join m_loan_transaction as lt on journalEntry.loan_transaction_id = lt.id ") 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 ") .append(" left join m_savings_account_transaction as st on journalEntry.savings_transaction_id = st.id ")
@@ -207,10 +207,10 @@ public class StandingInstructionApiResource {
if (associationParameters.contains("template")) { if (associationParameters.contains("template")) {
final StandingInstructionData templateData = standingInstructionReadPlatformService.retrieveTemplate( final StandingInstructionData templateData = standingInstructionReadPlatformService.retrieveTemplate(
standingInstructionData.getFromClient().getOfficeId(), standingInstructionData.getFromClient().getId(), standingInstructionData.getFromClient().getOfficeId(), standingInstructionData.getFromClient().getId(),
standingInstructionData.getFromAccount().getId(), standingInstructionData.getFromAccountType().getValue(), standingInstructionData.getFromAccount().getId(), standingInstructionData.fromAccountTypeEnum().getValue(),
standingInstructionData.getToClient().getOfficeId(), standingInstructionData.getToClient().getId(), standingInstructionData.getToClient().getOfficeId(), standingInstructionData.getToClient().getId(),
standingInstructionData.getToAccount().getId(), standingInstructionData.getToAccountType().getValue(), standingInstructionData.getToAccount().getId(), standingInstructionData.toAccountTypeEnum().getValue(),
standingInstructionData.getTransferType().getValue()); standingInstructionData.transferTypeEnum().getValue());
standingInstructionData = StandingInstructionData.withTemplateData(standingInstructionData, templateData); standingInstructionData = StandingInstructionData.withTemplateData(standingInstructionData, templateData);
} }
} }
@@ -59,7 +59,7 @@ public class AccountTransfersDetailDataValidator {
final Integer fromAccountType = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(fromAccountTypeParamName, element); final Integer fromAccountType = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(fromAccountTypeParamName, element);
baseDataValidator.reset().parameter(fromAccountTypeParamName).value(fromAccountType).notNull() baseDataValidator.reset().parameter(fromAccountTypeParamName).value(fromAccountType).notNull()
.isOneOfTheseValues(Integer.valueOf(1), Integer.valueOf(2)); .isOneOfTheseValues(Integer.valueOf(1), Integer.valueOf(2), Integer.valueOf(3));
final Long toOfficeId = this.fromApiJsonHelper.extractLongNamed(toOfficeIdParamName, element); final Long toOfficeId = this.fromApiJsonHelper.extractLongNamed(toOfficeIdParamName, element);
baseDataValidator.reset().parameter(toOfficeIdParamName).value(toOfficeId).notNull().integerGreaterThanZero(); baseDataValidator.reset().parameter(toOfficeIdParamName).value(toOfficeId).notNull().integerGreaterThanZero();
@@ -72,7 +72,7 @@ public class AccountTransfersDetailDataValidator {
final Integer toAccountType = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(toAccountTypeParamName, element); final Integer toAccountType = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(toAccountTypeParamName, element);
baseDataValidator.reset().parameter(toAccountTypeParamName).value(toAccountType).notNull().isOneOfTheseValues(Integer.valueOf(1), baseDataValidator.reset().parameter(toAccountTypeParamName).value(toAccountType).notNull().isOneOfTheseValues(Integer.valueOf(1),
Integer.valueOf(2)); Integer.valueOf(2), Integer.valueOf(3));
} }
@@ -48,20 +48,27 @@ public final class StandingInstructionData {
private final Long accountDetailId; private final Long accountDetailId;
@Getter @Getter
private final String name; private final String name;
@Getter
private final OfficeData fromOffice; private final OfficeData fromOffice;
@Getter @Getter
private final ClientData fromClient; private final ClientData fromClient;
@Getter
private final EnumOptionData fromAccountType; private final EnumOptionData fromAccountType;
@Getter @Getter
private final PortfolioAccountData fromAccount; private final PortfolioAccountData fromAccount;
@Getter
private final OfficeData toOffice; private final OfficeData toOffice;
@Getter @Getter
private final ClientData toClient; private final ClientData toClient;
@Getter
private final EnumOptionData toAccountType; private final EnumOptionData toAccountType;
@Getter @Getter
private final PortfolioAccountData toAccount; private final PortfolioAccountData toAccount;
@Getter
private final EnumOptionData transferType; private final EnumOptionData transferType;
@Getter
private final EnumOptionData priority; private final EnumOptionData priority;
@Getter
private final EnumOptionData instructionType; private final EnumOptionData instructionType;
@Getter @Getter
private final EnumOptionData status; private final EnumOptionData status;
@@ -69,27 +76,46 @@ public final class StandingInstructionData {
private final BigDecimal amount; private final BigDecimal amount;
@Getter @Getter
private final LocalDate validFrom; private final LocalDate validFrom;
@Getter
private final LocalDate validTill; private final LocalDate validTill;
@Getter
private final EnumOptionData recurrenceType; private final EnumOptionData recurrenceType;
@Getter
private final EnumOptionData recurrenceFrequency; private final EnumOptionData recurrenceFrequency;
@Getter @Getter
private final Integer recurrenceInterval; private final Integer recurrenceInterval;
@Getter
private final MonthDay recurrenceOnMonthDay; private final MonthDay recurrenceOnMonthDay;
@Getter
private final Page<AccountTransferData> transactions; private final Page<AccountTransferData> transactions;
@Getter
private final Collection<OfficeData> fromOfficeOptions; private final Collection<OfficeData> fromOfficeOptions;
@Getter
private final Collection<ClientData> fromClientOptions; private final Collection<ClientData> fromClientOptions;
@Getter
private final Collection<EnumOptionData> fromAccountTypeOptions; private final Collection<EnumOptionData> fromAccountTypeOptions;
@Getter
private final Collection<PortfolioAccountData> fromAccountOptions; private final Collection<PortfolioAccountData> fromAccountOptions;
@Getter
private final Collection<OfficeData> toOfficeOptions; private final Collection<OfficeData> toOfficeOptions;
@Getter
private final Collection<ClientData> toClientOptions; private final Collection<ClientData> toClientOptions;
@Getter
private final Collection<EnumOptionData> toAccountTypeOptions; private final Collection<EnumOptionData> toAccountTypeOptions;
@Getter
private final Collection<PortfolioAccountData> toAccountOptions; private final Collection<PortfolioAccountData> toAccountOptions;
@Getter
private final Collection<EnumOptionData> transferTypeOptions; private final Collection<EnumOptionData> transferTypeOptions;
@Getter
private final Collection<EnumOptionData> statusOptions; private final Collection<EnumOptionData> statusOptions;
@Getter
private final Collection<EnumOptionData> instructionTypeOptions; private final Collection<EnumOptionData> instructionTypeOptions;
@Getter
private final Collection<EnumOptionData> priorityOptions; private final Collection<EnumOptionData> priorityOptions;
@Getter
private final Collection<EnumOptionData> recurrenceTypeOptions; private final Collection<EnumOptionData> recurrenceTypeOptions;
@Getter
private final Collection<EnumOptionData> recurrenceFrequencyOptions; private final Collection<EnumOptionData> recurrenceFrequencyOptions;
public static StandingInstructionData template(final Collection<OfficeData> fromOfficeOptions, public static StandingInstructionData template(final Collection<OfficeData> fromOfficeOptions,
@@ -282,28 +308,32 @@ public final class StandingInstructionData {
instructionData.recurrenceTypeOptions, instructionData.recurrenceFrequencyOptions); instructionData.recurrenceTypeOptions, instructionData.recurrenceFrequencyOptions);
} }
public StandingInstructionType getInstructionType() { /**
* Returns the domain enum for instruction type — used internally by business logic.
* NOTE: renamed from getInstructionType() to avoid overriding Lombok @Getter (which must
* return EnumOptionData so Jackson serializes it as an object, not a plain string).
*/
public StandingInstructionType instructionTypeEnum() {
return Optional.ofNullable(this.instructionType).map(e -> StandingInstructionType.fromInt(e.getId().intValue())).orElse(null); return Optional.ofNullable(this.instructionType).map(e -> StandingInstructionType.fromInt(e.getId().intValue())).orElse(null);
} }
public AccountTransferRecurrenceType getRecurrenceType() { public AccountTransferRecurrenceType recurrenceTypeEnum() {
return Optional.ofNullable(this.recurrenceType).map(e -> AccountTransferRecurrenceType.fromInt(e.getId().intValue())).orElse(null); return Optional.ofNullable(this.recurrenceType).map(e -> AccountTransferRecurrenceType.fromInt(e.getId().intValue())).orElse(null);
} }
public PeriodFrequencyType getRecurrenceFrequency() { public PeriodFrequencyType recurrenceFrequencyEnum() {
return Optional.ofNullable(this.recurrenceFrequency).map(e -> PeriodFrequencyType.fromInt(e.getId().intValue())).orElse(null); return Optional.ofNullable(this.recurrenceFrequency).map(e -> PeriodFrequencyType.fromInt(e.getId().intValue())).orElse(null);
} }
public PortfolioAccountType getFromAccountType() { public PortfolioAccountType fromAccountTypeEnum() {
return Optional.ofNullable(this.fromAccountType).map(e -> PortfolioAccountType.fromInt(e.getId().intValue())).orElse(null); return Optional.ofNullable(this.fromAccountType).map(e -> PortfolioAccountType.fromInt(e.getId().intValue())).orElse(null);
} }
public PortfolioAccountType getToAccountType() { public PortfolioAccountType toAccountTypeEnum() {
return Optional.ofNullable(this.toAccountType).map(e -> PortfolioAccountType.fromInt(e.getId().intValue())).orElse(null); return Optional.ofNullable(this.toAccountType).map(e -> PortfolioAccountType.fromInt(e.getId().intValue())).orElse(null);
} }
public AccountTransferType getTransferType() { public AccountTransferType transferTypeEnum() {
return Optional.ofNullable(this.transferType).map(e -> AccountTransferType.fromInt(e.getId().intValue())).orElse(null); return Optional.ofNullable(this.transferType).map(e -> AccountTransferType.fromInt(e.getId().intValue())).orElse(null);
} }
@@ -317,10 +347,10 @@ public final class StandingInstructionData {
public Integer toTransferType() { public Integer toTransferType() {
Integer transferType = null; Integer transferType = null;
AccountTransferType accountTransferType = getTransferType(); AccountTransferType accountTransferType = transferTypeEnum();
if (accountTransferType.isChargePayment()) { if (accountTransferType != null && accountTransferType.isChargePayment()) {
transferType = LoanTransactionType.CHARGE_PAYMENT.getValue(); transferType = LoanTransactionType.CHARGE_PAYMENT.getValue();
} else if (accountTransferType.isLoanRepayment()) { } else if (accountTransferType != null && accountTransferType.isLoanRepayment()) {
transferType = LoanTransactionType.REPAYMENT.getValue(); transferType = LoanTransactionType.REPAYMENT.getValue();
} }
return transferType; return transferType;
@@ -171,7 +171,8 @@ public class StandingInstructionDataValidator {
baseDataValidator.reset().parameter(StandingInstructionApiConstants.nameParamName).value(name).notNull(); baseDataValidator.reset().parameter(StandingInstructionApiConstants.nameParamName).value(name).notNull();
final Integer toAccountType = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(toAccountTypeParamName, element); final Integer toAccountType = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(toAccountTypeParamName, element);
if (toAccountType != null && PortfolioAccountType.fromInt(toAccountType).isSavingsAccount()) { if (toAccountType != null && (PortfolioAccountType.fromInt(toAccountType).isSavingsAccount()
|| PortfolioAccountType.fromInt(toAccountType).isCurrentAccount())) {
baseDataValidator.reset().parameter(StandingInstructionApiConstants.instructionTypeParamName).value(standingInstructionType) baseDataValidator.reset().parameter(StandingInstructionApiConstants.instructionTypeParamName).value(standingInstructionType)
.notNull().inMinMaxRange(1, 1); .notNull().inMinMaxRange(1, 1);
baseDataValidator.reset().parameter(StandingInstructionApiConstants.recurrenceTypeParamName).value(recurrenceType).notNull() baseDataValidator.reset().parameter(StandingInstructionApiConstants.recurrenceTypeParamName).value(recurrenceType).notNull()
@@ -192,7 +193,8 @@ public class StandingInstructionDataValidator {
&& (fromPortfolioAccountType.isLoanAccount() || toPortfolioAccountType.isLoanAccount())) { && (fromPortfolioAccountType.isLoanAccount() || toPortfolioAccountType.isLoanAccount())) {
errorCode = "not.account.transfer"; errorCode = "not.account.transfer";
} else if (accountTransferType.isLoanRepayment() } else if (accountTransferType.isLoanRepayment()
&& (fromPortfolioAccountType.isLoanAccount() || toPortfolioAccountType.isSavingsAccount())) { && (fromPortfolioAccountType.isLoanAccount()
|| (toPortfolioAccountType.isSavingsAccount() || toPortfolioAccountType.isCurrentAccount()))) {
errorCode = "not.loan.repayment"; errorCode = "not.loan.repayment";
} }
if (errorCode != null) { if (errorCode != null) {
@@ -34,6 +34,8 @@ import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper; import org.apache.fineract.organisation.office.domain.OfficeRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler; import org.apache.fineract.portfolio.loanaccount.service.LoanAssembler;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
@@ -49,16 +51,19 @@ public class AccountTransferDetailAssembler {
private final SavingsAccountAssembler savingsAccountAssembler; private final SavingsAccountAssembler savingsAccountAssembler;
private final FromJsonHelper fromApiJsonHelper; private final FromJsonHelper fromApiJsonHelper;
private final LoanAssembler loanAccountAssembler; private final LoanAssembler loanAccountAssembler;
private final CurrentAccountRepository currentAccountRepository;
@Autowired @Autowired
public AccountTransferDetailAssembler(final ClientRepositoryWrapper clientRepository, public AccountTransferDetailAssembler(final ClientRepositoryWrapper clientRepository,
final OfficeRepositoryWrapper officeRepositoryWrapper, final SavingsAccountAssembler savingsAccountAssembler, final OfficeRepositoryWrapper officeRepositoryWrapper, final SavingsAccountAssembler savingsAccountAssembler,
final FromJsonHelper fromApiJsonHelper, final LoanAssembler loanAccountAssembler) { final FromJsonHelper fromApiJsonHelper, final LoanAssembler loanAccountAssembler,
final CurrentAccountRepository currentAccountRepository) {
this.clientRepository = clientRepository; this.clientRepository = clientRepository;
this.officeRepositoryWrapper = officeRepositoryWrapper; this.officeRepositoryWrapper = officeRepositoryWrapper;
this.savingsAccountAssembler = savingsAccountAssembler; this.savingsAccountAssembler = savingsAccountAssembler;
this.fromApiJsonHelper = fromApiJsonHelper; this.fromApiJsonHelper = fromApiJsonHelper;
this.loanAccountAssembler = loanAccountAssembler; this.loanAccountAssembler = loanAccountAssembler;
this.currentAccountRepository = currentAccountRepository;
} }
public AccountTransferDetails assembleSavingsToSavingsTransfer(final JsonCommand command) { public AccountTransferDetails assembleSavingsToSavingsTransfer(final JsonCommand command) {
@@ -211,4 +216,99 @@ public class AccountTransferDetailAssembler {
return AccountTransferDetails.loanToLoanTransfer(fromOffice, fromClient, fromLoanAccount, toOffice, toClient, toLoanAccount, return AccountTransferDetails.loanToLoanTransfer(fromOffice, fromClient, fromLoanAccount, toOffice, toClient, toLoanAccount,
transferType); transferType);
} }
// --- Current account assembly methods ---
public AccountTransferDetails assembleCurrentToCurrentTransfer(final JsonCommand command) {
final Long fromCurrentAccountId = command.longValueOfParameterNamed(fromAccountIdParamName);
final CurrentAccount fromCurrentAccount = currentAccountRepository.findById(fromCurrentAccountId)
.orElseThrow(() -> new IllegalArgumentException("From current account not found: " + fromCurrentAccountId));
final Long toCurrentAccountId = command.longValueOfParameterNamed(toAccountIdParamName);
final CurrentAccount toCurrentAccount = currentAccountRepository.findById(toCurrentAccountId)
.orElseThrow(() -> new IllegalArgumentException("To current account not found: " + toCurrentAccountId));
final JsonElement element = command.parsedJson();
final Long fromOfficeId = this.fromApiJsonHelper.extractLongNamed(fromOfficeIdParamName, element);
final Office fromOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(fromOfficeId);
final Long fromClientId = this.fromApiJsonHelper.extractLongNamed(fromClientIdParamName, element);
final Client fromClient = this.clientRepository.findOneWithNotFoundDetection(fromClientId);
final Long toOfficeId = this.fromApiJsonHelper.extractLongNamed(toOfficeIdParamName, element);
final Office toOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(toOfficeId);
final Long toClientId = this.fromApiJsonHelper.extractLongNamed(toClientIdParamName, element);
final Client toClient = this.clientRepository.findOneWithNotFoundDetection(toClientId);
final Integer transfertype = this.fromApiJsonHelper.extractIntegerNamed(transferTypeParamName, element, Locale.getDefault());
return AccountTransferDetails.currentToCurrentTransfer(fromOffice, fromClient, fromCurrentAccount, toOffice, toClient,
toCurrentAccount, transfertype);
}
public AccountTransferDetails assembleCurrentToSavingsTransfer(final JsonCommand command) {
final Long fromCurrentAccountId = command.longValueOfParameterNamed(fromAccountIdParamName);
final CurrentAccount fromCurrentAccount = currentAccountRepository.findById(fromCurrentAccountId)
.orElseThrow(() -> new IllegalArgumentException("From current account not found: " + fromCurrentAccountId));
final Long toSavingsId = command.longValueOfParameterNamed(toAccountIdParamName);
final SavingsAccount toSavingsAccount = this.savingsAccountAssembler.assembleFrom(toSavingsId, false);
final JsonElement element = command.parsedJson();
final Long fromOfficeId = this.fromApiJsonHelper.extractLongNamed(fromOfficeIdParamName, element);
final Office fromOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(fromOfficeId);
final Long fromClientId = this.fromApiJsonHelper.extractLongNamed(fromClientIdParamName, element);
final Client fromClient = this.clientRepository.findOneWithNotFoundDetection(fromClientId);
final Long toOfficeId = this.fromApiJsonHelper.extractLongNamed(toOfficeIdParamName, element);
final Office toOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(toOfficeId);
final Long toClientId = this.fromApiJsonHelper.extractLongNamed(toClientIdParamName, element);
final Client toClient = this.clientRepository.findOneWithNotFoundDetection(toClientId);
final Integer transfertype = this.fromApiJsonHelper.extractIntegerNamed(transferTypeParamName, element, Locale.getDefault());
return AccountTransferDetails.currentToSavingsTransfer(fromOffice, fromClient, fromCurrentAccount, toOffice, toClient,
toSavingsAccount, transfertype);
}
public AccountTransferDetails assembleSavingsToCurrentTransfer(final JsonCommand command) {
final Long fromSavingsId = command.longValueOfParameterNamed(fromAccountIdParamName);
final SavingsAccount fromSavingsAccount = this.savingsAccountAssembler.assembleFrom(fromSavingsId, false);
final Long toCurrentAccountId = command.longValueOfParameterNamed(toAccountIdParamName);
final CurrentAccount toCurrentAccount = currentAccountRepository.findById(toCurrentAccountId)
.orElseThrow(() -> new IllegalArgumentException("To current account not found: " + toCurrentAccountId));
final JsonElement element = command.parsedJson();
final Long fromOfficeId = this.fromApiJsonHelper.extractLongNamed(fromOfficeIdParamName, element);
final Office fromOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(fromOfficeId);
final Long fromClientId = this.fromApiJsonHelper.extractLongNamed(fromClientIdParamName, element);
final Client fromClient = this.clientRepository.findOneWithNotFoundDetection(fromClientId);
final Long toOfficeId = this.fromApiJsonHelper.extractLongNamed(toOfficeIdParamName, element);
final Office toOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(toOfficeId);
final Long toClientId = this.fromApiJsonHelper.extractLongNamed(toClientIdParamName, element);
final Client toClient = this.clientRepository.findOneWithNotFoundDetection(toClientId);
final Integer transfertype = this.fromApiJsonHelper.extractIntegerNamed(transferTypeParamName, element, Locale.getDefault());
return AccountTransferDetails.savingsToCurrentTransfer(fromOffice, fromClient, fromSavingsAccount, toOffice, toClient,
toCurrentAccount, transfertype);
}
public AccountTransferDetails assembleCurrentToLoanTransfer(final JsonCommand command) {
final Long fromCurrentAccountId = command.longValueOfParameterNamed(fromAccountIdParamName);
final CurrentAccount fromCurrentAccount = currentAccountRepository.findById(fromCurrentAccountId)
.orElseThrow(() -> new IllegalArgumentException("From current account not found: " + fromCurrentAccountId));
final Long toLoanAccountId = command.longValueOfParameterNamed(toAccountIdParamName);
final Loan toLoanAccount = this.loanAccountAssembler.assembleFrom(toLoanAccountId);
final JsonElement element = command.parsedJson();
final Long fromOfficeId = this.fromApiJsonHelper.extractLongNamed(fromOfficeIdParamName, element);
final Office fromOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(fromOfficeId);
final Long fromClientId = this.fromApiJsonHelper.extractLongNamed(fromClientIdParamName, element);
final Client fromClient = this.clientRepository.findOneWithNotFoundDetection(fromClientId);
final Long toOfficeId = this.fromApiJsonHelper.extractLongNamed(toOfficeIdParamName, element);
final Office toOffice = this.officeRepositoryWrapper.findOneWithNotFoundDetection(toOfficeId);
final Long toClientId = this.fromApiJsonHelper.extractLongNamed(toClientIdParamName, element);
final Client toClient = this.clientRepository.findOneWithNotFoundDetection(toClientId);
final Integer transfertype = this.fromApiJsonHelper.extractIntegerNamed(transferTypeParamName, element, Locale.getDefault());
return AccountTransferDetails.currentToLoanTransfer(fromOffice, fromClient, fromCurrentAccount, toOffice, toClient,
toLoanAccount, transfertype);
}
} }
@@ -32,6 +32,7 @@ import java.util.List;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.organisation.office.domain.Office; import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
@@ -71,6 +72,14 @@ public class AccountTransferDetails extends AbstractPersistableCustom<Long> {
@JoinColumn(name = "from_loan_account_id", nullable = true) @JoinColumn(name = "from_loan_account_id", nullable = true)
private Loan fromLoanAccount; private Loan fromLoanAccount;
@ManyToOne
@JoinColumn(name = "from_current_account_id", nullable = true)
private CurrentAccount fromCurrentAccount;
@ManyToOne
@JoinColumn(name = "to_current_account_id", nullable = true)
private CurrentAccount toCurrentAccount;
@Column(name = "transfer_type") @Column(name = "transfer_type")
private Integer transferType; private Integer transferType;
@@ -84,21 +93,21 @@ public class AccountTransferDetails extends AbstractPersistableCustom<Long> {
final SavingsAccount fromSavingsAccount, final Office toOffice, final Client toClient, final SavingsAccount toSavingsAccount, final SavingsAccount fromSavingsAccount, final Office toOffice, final Client toClient, final SavingsAccount toSavingsAccount,
Integer transferType) { Integer transferType) {
return new AccountTransferDetails(fromOffice, fromClient, fromSavingsAccount, null, toOffice, toClient, toSavingsAccount, null, return new AccountTransferDetails(fromOffice, fromClient, fromSavingsAccount, null, null, toOffice, toClient, toSavingsAccount,
transferType, null); null, null, transferType, null);
} }
public static AccountTransferDetails savingsToLoanTransfer(final Office fromOffice, final Client fromClient, public static AccountTransferDetails savingsToLoanTransfer(final Office fromOffice, final Client fromClient,
final SavingsAccount fromSavingsAccount, final Office toOffice, final Client toClient, final Loan toLoanAccount, final SavingsAccount fromSavingsAccount, final Office toOffice, final Client toClient, final Loan toLoanAccount,
Integer transferType) { Integer transferType) {
return new AccountTransferDetails(fromOffice, fromClient, fromSavingsAccount, null, toOffice, toClient, null, toLoanAccount, return new AccountTransferDetails(fromOffice, fromClient, fromSavingsAccount, null, null, toOffice, toClient, null, toLoanAccount,
transferType, null); null, transferType, null);
} }
public static AccountTransferDetails loanTosavingsTransfer(final Office fromOffice, final Client fromClient, final Loan fromLoanAccount, public static AccountTransferDetails loanTosavingsTransfer(final Office fromOffice, final Client fromClient, final Loan fromLoanAccount,
final Office toOffice, final Client toClient, final SavingsAccount toSavingsAccount, Integer transferType) { final Office toOffice, final Client toClient, final SavingsAccount toSavingsAccount, Integer transferType) {
return new AccountTransferDetails(fromOffice, fromClient, null, fromLoanAccount, toOffice, toClient, toSavingsAccount, null, return new AccountTransferDetails(fromOffice, fromClient, null, fromLoanAccount, null, toOffice, toClient, toSavingsAccount, null,
transferType, null); null, transferType, null);
} }
protected AccountTransferDetails() { protected AccountTransferDetails() {
@@ -106,17 +115,21 @@ public class AccountTransferDetails extends AbstractPersistableCustom<Long> {
} }
private AccountTransferDetails(final Office fromOffice, final Client fromClient, final SavingsAccount fromSavingsAccount, private AccountTransferDetails(final Office fromOffice, final Client fromClient, final SavingsAccount fromSavingsAccount,
final Loan fromLoanAccount, final Office toOffice, final Client toClient, final SavingsAccount toSavingsAccount, final Loan fromLoanAccount, final CurrentAccount fromCurrentAccount,
final Loan toLoanAccount, final Integer transferType, final Office toOffice, final Client toClient, final SavingsAccount toSavingsAccount,
final Loan toLoanAccount, final CurrentAccount toCurrentAccount,
final Integer transferType,
final AccountTransferStandingInstruction accountTransferStandingInstruction) { final AccountTransferStandingInstruction accountTransferStandingInstruction) {
this.fromOffice = fromOffice; this.fromOffice = fromOffice;
this.fromClient = fromClient; this.fromClient = fromClient;
this.fromSavingsAccount = fromSavingsAccount; this.fromSavingsAccount = fromSavingsAccount;
this.fromLoanAccount = fromLoanAccount; this.fromLoanAccount = fromLoanAccount;
this.fromCurrentAccount = fromCurrentAccount;
this.toOffice = toOffice; this.toOffice = toOffice;
this.toClient = toClient; this.toClient = toClient;
this.toSavingsAccount = toSavingsAccount; this.toSavingsAccount = toSavingsAccount;
this.toLoanAccount = toLoanAccount; this.toLoanAccount = toLoanAccount;
this.toCurrentAccount = toCurrentAccount;
this.transferType = transferType; this.transferType = transferType;
this.accountTransferStandingInstruction = accountTransferStandingInstruction; this.accountTransferStandingInstruction = accountTransferStandingInstruction;
} }
@@ -145,6 +158,14 @@ public class AccountTransferDetails extends AbstractPersistableCustom<Long> {
return this.fromLoanAccount; return this.fromLoanAccount;
} }
public CurrentAccount fromCurrentAccount() {
return this.fromCurrentAccount;
}
public CurrentAccount toCurrentAccount() {
return this.toCurrentAccount;
}
public AccountTransferStandingInstruction accountTransferStandingInstruction() { public AccountTransferStandingInstruction accountTransferStandingInstruction() {
return this.accountTransferStandingInstruction; return this.accountTransferStandingInstruction;
} }
@@ -155,8 +176,38 @@ public class AccountTransferDetails extends AbstractPersistableCustom<Long> {
public static AccountTransferDetails loanToLoanTransfer(Office fromOffice, Client fromClient, Loan fromLoanAccount, Office toOffice, public static AccountTransferDetails loanToLoanTransfer(Office fromOffice, Client fromClient, Loan fromLoanAccount, Office toOffice,
Client toClient, Loan toLoanAccount, Integer transferType) { Client toClient, Loan toLoanAccount, Integer transferType) {
return new AccountTransferDetails(fromOffice, fromClient, null, fromLoanAccount, toOffice, toClient, null, toLoanAccount, return new AccountTransferDetails(fromOffice, fromClient, null, fromLoanAccount, null, toOffice, toClient, null, toLoanAccount,
transferType, null); null, transferType, null);
}
// --- Current account factory methods ---
public static AccountTransferDetails currentToCurrentTransfer(final Office fromOffice, final Client fromClient,
final CurrentAccount fromCurrentAccount, final Office toOffice, final Client toClient,
final CurrentAccount toCurrentAccount, Integer transferType) {
return new AccountTransferDetails(fromOffice, fromClient, null, null, fromCurrentAccount, toOffice, toClient, null, null,
toCurrentAccount, transferType, null);
}
public static AccountTransferDetails currentToSavingsTransfer(final Office fromOffice, final Client fromClient,
final CurrentAccount fromCurrentAccount, final Office toOffice, final Client toClient,
final SavingsAccount toSavingsAccount, Integer transferType) {
return new AccountTransferDetails(fromOffice, fromClient, null, null, fromCurrentAccount, toOffice, toClient, toSavingsAccount,
null, null, transferType, null);
}
public static AccountTransferDetails savingsToCurrentTransfer(final Office fromOffice, final Client fromClient,
final SavingsAccount fromSavingsAccount, final Office toOffice, final Client toClient,
final CurrentAccount toCurrentAccount, Integer transferType) {
return new AccountTransferDetails(fromOffice, fromClient, fromSavingsAccount, null, null, toOffice, toClient, null, null,
toCurrentAccount, transferType, null);
}
public static AccountTransferDetails currentToLoanTransfer(final Office fromOffice, final Client fromClient,
final CurrentAccount fromCurrentAccount, final Office toOffice, final Client toClient,
final Loan toLoanAccount, Integer transferType) {
return new AccountTransferDetails(fromOffice, fromClient, null, null, fromCurrentAccount, toOffice, toClient, null, toLoanAccount,
null, transferType, null);
} }
public List<AccountTransferTransaction> getAccountTransferTransactions() { public List<AccountTransferTransaction> getAccountTransferTransactions() {
@@ -249,7 +249,9 @@ public class AccountTransferStandingInstruction extends AbstractPersistableCusto
} }
} }
if (this.accountTransferDetails.toSavingsAccount() != null) { // For savings-to-savings or current-to-savings: enforce Fixed instruction + Periodic recurrence
if (this.accountTransferDetails.toSavingsAccount() != null
|| this.accountTransferDetails.toCurrentAccount() != null) {
baseDataValidator.reset().parameter(instructionTypeParamName).value(this.instructionType).notNull().inMinMaxRange(1, 1); baseDataValidator.reset().parameter(instructionTypeParamName).value(this.instructionType).notNull().inMinMaxRange(1, 1);
baseDataValidator.reset().parameter(recurrenceTypeParamName).value(this.recurrenceType).notNull().inMinMaxRange(1, 1); baseDataValidator.reset().parameter(recurrenceTypeParamName).value(this.recurrenceType).notNull().inMinMaxRange(1, 1);
} }
@@ -258,12 +260,22 @@ public class AccountTransferStandingInstruction extends AbstractPersistableCusto
baseDataValidator.reset().parameter(amountParamName).value(this.amount).notNull(); baseDataValidator.reset().parameter(amountParamName).value(this.amount).notNull();
} }
// Account Transfer: from must be savings OR current; to must be savings OR current
boolean fromAccountValid = this.accountTransferDetails.fromSavingsAccount() != null
|| this.accountTransferDetails.fromCurrentAccount() != null;
boolean toAccountValid = this.accountTransferDetails.toSavingsAccount() != null
|| this.accountTransferDetails.toCurrentAccount() != null;
// Loan Repayment: from must be savings OR current; to must be loan
boolean fromAccountValidForLoan = this.accountTransferDetails.fromSavingsAccount() != null
|| this.accountTransferDetails.fromCurrentAccount() != null;
String errorCode = null; String errorCode = null;
if (this.accountTransferDetails.transferType().isAccountTransfer() if (this.accountTransferDetails.transferType().isAccountTransfer()
&& (this.accountTransferDetails.fromSavingsAccount() == null || this.accountTransferDetails.toSavingsAccount() == null)) { && !(fromAccountValid && toAccountValid)) {
errorCode = "not.account.transfer"; errorCode = "not.account.transfer";
} else if (this.accountTransferDetails.transferType().isLoanRepayment() } else if (this.accountTransferDetails.transferType().isLoanRepayment()
&& (this.accountTransferDetails.fromSavingsAccount() == null || this.accountTransferDetails.toLoanAccount() == null)) { && !(fromAccountValidForLoan && this.accountTransferDetails.toLoanAccount() != null)) {
errorCode = "not.loan.repayment"; errorCode = "not.loan.repayment";
} }
if (errorCode != null) { if (errorCode != null) {
@@ -35,6 +35,9 @@ import java.time.LocalDate;
import java.time.MonthDay; import java.time.MonthDay;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -61,8 +64,13 @@ public class StandingInstructionAssembler {
BigDecimal amount = null; BigDecimal amount = null;
final BigDecimal transferAmount = command.bigDecimalValueOfParameterNamed(amountParamName); final BigDecimal transferAmount = command.bigDecimalValueOfParameterNamed(amountParamName);
if (transferAmount != null) { if (transferAmount != null) {
final Money monetaryAmount = Money.of(accountTransferDetails.fromSavingsAccount().getCurrency(), transferAmount); final MonetaryCurrency currency = resolveCurrency(accountTransferDetails);
amount = monetaryAmount.getAmount(); if (currency != null) {
final Money monetaryAmount = Money.of(currency, transferAmount);
amount = monetaryAmount.getAmount();
} else {
amount = transferAmount;
}
} }
final Integer status = command.integerValueOfParameterNamed(statusParamName); final Integer status = command.integerValueOfParameterNamed(statusParamName);
final Integer priority = command.integerValueOfParameterNamed(priorityParamName); final Integer priority = command.integerValueOfParameterNamed(priorityParamName);
@@ -78,6 +86,22 @@ public class StandingInstructionAssembler {
accountTransferDetails.updateAccountTransferStandingInstruction(accountTransferStandingInstruction); accountTransferDetails.updateAccountTransferStandingInstruction(accountTransferStandingInstruction);
} }
/**
* Resolves the currency from the "from" account — savings, loan (null), or current account.
*/
private MonetaryCurrency resolveCurrency(final AccountTransferDetails details) {
final SavingsAccount fromSavings = details.fromSavingsAccount();
if (fromSavings != null) {
return fromSavings.getCurrency();
}
final CurrentAccount fromCurrent = details.fromCurrentAccount();
if (fromCurrent != null) {
return new MonetaryCurrency(fromCurrent.getCurrencyCode(), fromCurrent.getCurrencyDigits(),
fromCurrent.getCurrencyMultiplesOf());
}
return null; // loan accounts don't have a direct currency on AccountTransferDetails
}
public AccountTransferDetails assembleSavingsToLoanTransfer(final JsonCommand command) { public AccountTransferDetails assembleSavingsToLoanTransfer(final JsonCommand command) {
final AccountTransferDetails accountTransferDetails = this.accountTransferDetailAssembler.assembleSavingsToLoanTransfer(command); final AccountTransferDetails accountTransferDetails = this.accountTransferDetailAssembler.assembleSavingsToLoanTransfer(command);
assembleStandingInstruction(command, accountTransferDetails); assembleStandingInstruction(command, accountTransferDetails);
@@ -90,4 +114,30 @@ public class StandingInstructionAssembler {
return accountTransferDetails; return accountTransferDetails;
} }
// --- Current account methods ---
public AccountTransferDetails assembleCurrentToCurrentTransfer(final JsonCommand command) {
final AccountTransferDetails accountTransferDetails = this.accountTransferDetailAssembler.assembleCurrentToCurrentTransfer(command);
assembleStandingInstruction(command, accountTransferDetails);
return accountTransferDetails;
}
public AccountTransferDetails assembleCurrentToSavingsTransfer(final JsonCommand command) {
final AccountTransferDetails accountTransferDetails = this.accountTransferDetailAssembler.assembleCurrentToSavingsTransfer(command);
assembleStandingInstruction(command, accountTransferDetails);
return accountTransferDetails;
}
public AccountTransferDetails assembleSavingsToCurrentTransfer(final JsonCommand command) {
final AccountTransferDetails accountTransferDetails = this.accountTransferDetailAssembler.assembleSavingsToCurrentTransfer(command);
assembleStandingInstruction(command, accountTransferDetails);
return accountTransferDetails;
}
public AccountTransferDetails assembleCurrentToLoanTransfer(final JsonCommand command) {
final AccountTransferDetails accountTransferDetails = this.accountTransferDetailAssembler.assembleCurrentToLoanTransfer(command);
assembleStandingInstruction(command, accountTransferDetails);
return accountTransferDetails;
}
} }
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.account.jobs.executestandinginstructions;
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
import org.apache.fineract.infrastructure.jobs.service.JobName; import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService; import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService; import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService;
import org.springframework.batch.core.Job; import org.springframework.batch.core.Job;
@@ -49,6 +50,8 @@ public class ExecuteStandingInstructionsConfig {
private DatabaseSpecificSQLGenerator sqlGenerator; private DatabaseSpecificSQLGenerator sqlGenerator;
@Autowired @Autowired
private AccountTransfersWritePlatformService accountTransfersWritePlatformService; private AccountTransfersWritePlatformService accountTransfersWritePlatformService;
@Autowired
private AccountTransferDetailRepository accountTransferDetailRepository;
@Bean @Bean
protected Step executeStandingInstructionsStep() { protected Step executeStandingInstructionsStep() {
@@ -65,6 +68,6 @@ public class ExecuteStandingInstructionsConfig {
@Bean @Bean
public ExecuteStandingInstructionsTasklet executeStandingInstructionsTasklet() { public ExecuteStandingInstructionsTasklet executeStandingInstructionsTasklet() {
return new ExecuteStandingInstructionsTasklet(standingInstructionReadPlatformService, jdbcTemplate, sqlGenerator, return new ExecuteStandingInstructionsTasklet(standingInstructionReadPlatformService, jdbcTemplate, sqlGenerator,
accountTransfersWritePlatformService); accountTransfersWritePlatformService, accountTransferDetailRepository);
} }
} }
@@ -34,6 +34,8 @@ import org.apache.fineract.infrastructure.jobs.exception.JobExecutionException;
import org.apache.fineract.portfolio.account.data.AccountTransferDTO; import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
import org.apache.fineract.portfolio.account.data.StandingInstructionData; import org.apache.fineract.portfolio.account.data.StandingInstructionData;
import org.apache.fineract.portfolio.account.data.StandingInstructionDuesData; import org.apache.fineract.portfolio.account.data.StandingInstructionDuesData;
import org.apache.fineract.portfolio.account.domain.AccountTransferDetailRepository;
import org.apache.fineract.portfolio.account.domain.AccountTransferDetails;
import org.apache.fineract.portfolio.account.domain.AccountTransferRecurrenceType; import org.apache.fineract.portfolio.account.domain.AccountTransferRecurrenceType;
import org.apache.fineract.portfolio.account.domain.StandingInstructionStatus; import org.apache.fineract.portfolio.account.domain.StandingInstructionStatus;
import org.apache.fineract.portfolio.account.domain.StandingInstructionType; import org.apache.fineract.portfolio.account.domain.StandingInstructionType;
@@ -58,6 +60,7 @@ public class ExecuteStandingInstructionsTasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final DatabaseSpecificSQLGenerator sqlGenerator; private final DatabaseSpecificSQLGenerator sqlGenerator;
private final AccountTransfersWritePlatformService accountTransfersWritePlatformService; private final AccountTransfersWritePlatformService accountTransfersWritePlatformService;
private final AccountTransferDetailRepository accountTransferDetailRepository;
@Override @Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
@@ -66,12 +69,12 @@ public class ExecuteStandingInstructionsTasklet implements Tasklet {
List<Throwable> errors = new ArrayList<>(); List<Throwable> errors = new ArrayList<>();
for (StandingInstructionData data : instructionData) { for (StandingInstructionData data : instructionData) {
boolean isDueForTransfer = false; boolean isDueForTransfer = false;
AccountTransferRecurrenceType recurrenceType = data.getRecurrenceType(); AccountTransferRecurrenceType recurrenceType = data.recurrenceTypeEnum();
StandingInstructionType instructionType = data.getInstructionType(); StandingInstructionType instructionType = data.instructionTypeEnum();
LocalDate transactionDate = DateUtils.getBusinessLocalDate(); LocalDate transactionDate = DateUtils.getBusinessLocalDate();
if (recurrenceType.isPeriodicRecurrence()) { if (recurrenceType.isPeriodicRecurrence()) {
final ScheduledDateGenerator scheduledDateGenerator = new DefaultScheduledDateGenerator(); final ScheduledDateGenerator scheduledDateGenerator = new DefaultScheduledDateGenerator();
PeriodFrequencyType frequencyType = data.getRecurrenceFrequency(); PeriodFrequencyType frequencyType = data.recurrenceFrequencyEnum();
LocalDate startDate = data.getValidFrom(); LocalDate startDate = data.getValidFrom();
if (frequencyType.isMonthly()) { if (frequencyType.isMonthly()) {
startDate = startDate.withDayOfMonth(data.getRecurrenceOnDay()); startDate = startDate.withDayOfMonth(data.getRecurrenceOnDay());
@@ -89,11 +92,11 @@ public class ExecuteStandingInstructionsTasklet implements Tasklet {
} }
BigDecimal transactionAmount = data.getAmount(); BigDecimal transactionAmount = data.getAmount();
if (data.getToAccountType().isLoanAccount() if (data.toAccountTypeEnum() != null && data.toAccountTypeEnum().isLoanAccount()
&& (recurrenceType.isDuesRecurrence() || (isDueForTransfer && instructionType.isDuesAmoutTransfer()))) { && (recurrenceType.isDuesRecurrence() || (isDueForTransfer && instructionType.isDuesAmoutTransfer()))) {
StandingInstructionDuesData standingInstructionDuesData = standingInstructionReadPlatformService StandingInstructionDuesData standingInstructionDuesData = standingInstructionReadPlatformService
.retriveLoanDuesData(data.getToAccount().getId()); .retriveLoanDuesData(data.getToAccount().getId());
if (data.getInstructionType().isDuesAmoutTransfer()) { if (data.instructionTypeEnum().isDuesAmoutTransfer()) {
transactionAmount = standingInstructionDuesData.totalDueAmount(); transactionAmount = standingInstructionDuesData.totalDueAmount();
} }
if (recurrenceType.isDuesRecurrence()) { if (recurrenceType.isDuesRecurrence()) {
@@ -105,10 +108,15 @@ public class ExecuteStandingInstructionsTasklet implements Tasklet {
final SavingsAccount fromSavingsAccount = null; final SavingsAccount fromSavingsAccount = null;
final boolean isRegularTransaction = true; final boolean isRegularTransaction = true;
final boolean isExceptionForBalanceCheck = false; final boolean isExceptionForBalanceCheck = false;
// Load existing AccountTransferDetails so transactions are linked to
// this standing instruction and appear in its transaction history
final AccountTransferDetails existingDetails = data.getAccountDetailId() != null
? accountTransferDetailRepository.findById(data.getAccountDetailId()).orElse(null)
: null;
AccountTransferDTO accountTransferDTO = new AccountTransferDTO(transactionDate, transactionAmount, AccountTransferDTO accountTransferDTO = new AccountTransferDTO(transactionDate, transactionAmount,
data.getFromAccountType(), data.getToAccountType(), data.getFromAccount().getId(), data.getToAccount().getId(), data.fromAccountTypeEnum(), data.toAccountTypeEnum(), data.getFromAccount().getId(), data.getToAccount().getId(),
data.getName() + " Standing instruction trasfer ", null, null, null, null, data.toTransferType(), null, null, data.getName() + " Standing instruction trasfer ", null, null, null, null, data.toTransferType(), null, null,
data.getTransferType().getValue(), null, null, ExternalId.empty(), null, null, fromSavingsAccount, data.transferTypeEnum().getValue(), existingDetails, null, ExternalId.empty(), null, null, fromSavingsAccount,
isRegularTransaction, isExceptionForBalanceCheck); isRegularTransaction, isExceptionForBalanceCheck);
final boolean transferCompleted = transferAmount(errors, accountTransferDTO, data.getId()); final boolean transferCompleted = transferAmount(errors, accountTransferDTO, data.getId());
@@ -52,6 +52,10 @@ public final class AccountTransferEnumerations {
optionData = new EnumOptionData(PortfolioAccountType.SAVINGS.getValue().longValue(), optionData = new EnumOptionData(PortfolioAccountType.SAVINGS.getValue().longValue(),
PortfolioAccountType.SAVINGS.getCode(), "Savings Account"); PortfolioAccountType.SAVINGS.getCode(), "Savings Account");
break; break;
case CURRENT:
optionData = new EnumOptionData(PortfolioAccountType.CURRENT.getValue().longValue(),
PortfolioAccountType.CURRENT.getCode(), "Current Account");
break;
} }
} }
@@ -34,6 +34,9 @@ import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings;
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.config.FineractProperties; import org.apache.fineract.infrastructure.core.config.FineractProperties;
@@ -42,6 +45,8 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuild
import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.portfolio.account.PortfolioAccountType; import org.apache.fineract.portfolio.account.PortfolioAccountType;
import org.apache.fineract.portfolio.account.data.AccountTransferDTO; import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator; import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator;
@@ -52,6 +57,10 @@ import org.apache.fineract.portfolio.account.domain.AccountTransferRepository;
import org.apache.fineract.portfolio.account.domain.AccountTransferTransaction; import org.apache.fineract.portfolio.account.domain.AccountTransferTransaction;
import org.apache.fineract.portfolio.account.domain.AccountTransferType; import org.apache.fineract.portfolio.account.domain.AccountTransferType;
import org.apache.fineract.portfolio.account.exception.DifferentCurrenciesException; import org.apache.fineract.portfolio.account.exception.DifferentCurrenciesException;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccount;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO; import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO;
import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService; import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService;
@@ -88,6 +97,9 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
private final ConfigurationDomainService configurationDomainService; private final ConfigurationDomainService configurationDomainService;
private final ExternalIdFactory externalIdFactory; private final ExternalIdFactory externalIdFactory;
private final FineractProperties fineractProperties; private final FineractProperties fineractProperties;
private final CurrentAccountRepository currentAccountRepository;
private final CurrentAccountTransactionRepository currentAccountTransactionRepository;
private final AccountingProcessorHelper accountingProcessorHelper;
@Transactional @Transactional
@Override @Override
@@ -448,6 +460,144 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
gsim.setParentDeposit(newBalance); gsim.setParentDeposit(newBalance);
gsimRepository.save(gsim); gsimRepository.save(gsim);
} }
} else if (isCurrentToSavingsAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) {
// --- Current Account → Savings Account ---
final CurrentAccount fromCurrentAccount = currentAccountRepository
.findById(accountTransferDTO.getFromAccountId())
.orElseThrow(() -> new GeneralPlatformDomainRuleException(
"error.msg.currentaccount.not.found", "Current account not found: " + accountTransferDTO.getFromAccountId()));
final SavingsAccount toSavingsAccount = this.savingsAccountAssembler
.assembleFrom(accountTransferDTO.getToAccountId(), backdatedTxnsAllowedTill);
final CurrentAccountTransaction withdrawal = performCurrentWithdrawal(fromCurrentAccount,
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionDate(),
toSavingsAccount.productId(), toSavingsAccount.getId());
// skipAccountingPostings=true: current account withdrawal leg (C-txn) already posts
// DR CURRENT ACCOUNT / CR SAVING INDIVIDUAL directly — each with its own account number.
// Savings deposit GL entries would produce a self-cancelling DR+CR on SAVING INDIVIDUAL — suppress them.
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(),
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill, true);
if (accountTransferDetails == null) {
accountTransferDetails = AccountTransferDetails.currentToSavingsTransfer(
fromCurrentAccount.getOffice(), fromCurrentAccount.getClient(), fromCurrentAccount,
toSavingsAccount.office(), toSavingsAccount.getClient(), toSavingsAccount,
accountTransferDTO.getTransferType());
}
final Money amount = Money.of(toSavingsAccount.getCurrency(), accountTransferDTO.getTransactionAmount());
accountTransferDetails.addAccountTransferTransaction(AccountTransferTransaction.savingsToSavingsTransfer(
accountTransferDetails, null, deposit, accountTransferDTO.getTransactionDate(), amount,
accountTransferDTO.getDescription()));
this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
transferTransactionId = accountTransferDetails.getId();
} else if (isSavingsToCurrentAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) {
// --- Savings Account → Current Account ---
final SavingsAccount fromSavingsAccount = this.savingsAccountAssembler
.assembleFrom(accountTransferDTO.getFromAccountId(), backdatedTxnsAllowedTill);
final CurrentAccount toCurrentAccount = currentAccountRepository
.findById(accountTransferDTO.getToAccountId())
.orElseThrow(() -> new GeneralPlatformDomainRuleException(
"error.msg.currentaccount.not.found", "Current account not found: " + accountTransferDTO.getToAccountId()));
// skipAccountingPostings=true: current account deposit leg (C-txn) posts
// DR SAVING INDIVIDUAL / CR CURRENT ACCOUNT directly. Savings withdrawal GL entries
// would produce a self-cancelling DR+CR on the same SAVING INDIVIDUAL GL — suppress them.
final SavingsTransactionBooleanValues txnBooleans = new SavingsTransactionBooleanValues(isAccountTransfer,
isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), false,
accountTransferDTO.isExceptionForBalanceCheck(), true);
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount,
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(),
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
txnBooleans, backdatedTxnsAllowedTill);
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate(),
fromSavingsAccount.productId(), fromSavingsAccount.getId());
if (accountTransferDetails == null) {
accountTransferDetails = AccountTransferDetails.savingsToCurrentTransfer(
fromSavingsAccount.office(), fromSavingsAccount.getClient(), fromSavingsAccount,
toCurrentAccount.getOffice(), toCurrentAccount.getClient(), toCurrentAccount,
accountTransferDTO.getTransferType());
}
final Money amount = Money.of(fromSavingsAccount.getCurrency(), accountTransferDTO.getTransactionAmount());
accountTransferDetails.addAccountTransferTransaction(AccountTransferTransaction.savingsToSavingsTransfer(
accountTransferDetails, withdrawal, null, accountTransferDTO.getTransactionDate(), amount,
accountTransferDTO.getDescription()));
this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
transferTransactionId = accountTransferDetails.getId();
} else if (isCurrentToCurrentAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) {
// --- Current Account → Current Account ---
final CurrentAccount fromCurrentAccount = currentAccountRepository
.findById(accountTransferDTO.getFromAccountId())
.orElseThrow(() -> new GeneralPlatformDomainRuleException(
"error.msg.currentaccount.not.found", "Current account not found: " + accountTransferDTO.getFromAccountId()));
final CurrentAccount toCurrentAccount = currentAccountRepository
.findById(accountTransferDTO.getToAccountId())
.orElseThrow(() -> new GeneralPlatformDomainRuleException(
"error.msg.currentaccount.not.found", "Current account not found: " + accountTransferDTO.getToAccountId()));
performCurrentWithdrawal(fromCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate(),
toCurrentAccount.getProductId(), toCurrentAccount.getId());
performCurrentDeposit(toCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate(),
fromCurrentAccount.getProductId(), fromCurrentAccount.getId());
if (accountTransferDetails == null) {
accountTransferDetails = AccountTransferDetails.currentToCurrentTransfer(
fromCurrentAccount.getOffice(), fromCurrentAccount.getClient(), fromCurrentAccount,
toCurrentAccount.getOffice(), toCurrentAccount.getClient(), toCurrentAccount,
accountTransferDTO.getTransferType());
}
final MonetaryCurrency currency = new MonetaryCurrency(fromCurrentAccount.getCurrencyCode(),
fromCurrentAccount.getCurrencyDigits(), fromCurrentAccount.getCurrencyMultiplesOf());
final Money amount = Money.of(currency, accountTransferDTO.getTransactionAmount());
accountTransferDetails.addAccountTransferTransaction(AccountTransferTransaction.savingsToSavingsTransfer(
accountTransferDetails, null, null, accountTransferDTO.getTransactionDate(), amount,
accountTransferDTO.getDescription()));
this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
transferTransactionId = accountTransferDetails.getId();
} else if (isCurrentToLoanAccountTransfer(accountTransferDTO.getFromAccountType(), accountTransferDTO.getToAccountType())) {
// --- Current Account → Loan Account ---
final CurrentAccount fromCurrentAccount = currentAccountRepository
.findById(accountTransferDTO.getFromAccountId())
.orElseThrow(() -> new GeneralPlatformDomainRuleException(
"error.msg.currentaccount.not.found", "Current account not found: " + accountTransferDTO.getFromAccountId()));
final Loan toLoanAccount = this.loanAccountAssembler.assembleFrom(accountTransferDTO.getToAccountId());
performCurrentWithdrawalForLoan(fromCurrentAccount, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getTransactionDate());
ExternalId externalId = externalIdFactory.create(accountTransferDTO.getTxnExternalId().getValue());
final boolean isRecoveryRepayment = false;
final Boolean isHolidayValidationDone = false;
final String chargeRefundChargeType = null;
LoanTransaction loanTransaction = this.loanAccountDomainService.makeRepayment(LoanTransactionType.REPAYMENT,
toLoanAccount, accountTransferDTO.getTransactionDate(), accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getPaymentDetail(), null, externalId, isRecoveryRepayment,
chargeRefundChargeType, isAccountTransfer, null, isHolidayValidationDone);
final AccountTransferDetails newDetails = AccountTransferDetails.currentToLoanTransfer(
fromCurrentAccount.getOffice(), fromCurrentAccount.getClient(), fromCurrentAccount,
toLoanAccount.getOffice(), toLoanAccount.client(), toLoanAccount,
accountTransferDTO.getTransferType());
final MonetaryCurrency currency = new MonetaryCurrency(fromCurrentAccount.getCurrencyCode(),
fromCurrentAccount.getCurrencyDigits(), fromCurrentAccount.getCurrencyMultiplesOf());
final Money amount = Money.of(currency, accountTransferDTO.getTransactionAmount());
newDetails.addAccountTransferTransaction(AccountTransferTransaction.savingsToLoanTransfer(
newDetails, null, loanTransaction, accountTransferDTO.getTransactionDate(), amount,
accountTransferDTO.getDescription()));
this.accountTransferDetailRepository.saveAndFlush(newDetails);
transferTransactionId = newDetails.getId();
} else { } else {
throw new GeneralPlatformDomainRuleException("error.msg.accounttransfer.loan.to.loan.not.supported", throw new GeneralPlatformDomainRuleException("error.msg.accounttransfer.loan.to.loan.not.supported",
"Account transfer from loan to another loan is not supported"); "Account transfer from loan to another loan is not supported");
@@ -505,6 +655,160 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
return fromAccountType.isSavingsAccount() && toAccountType.isSavingsAccount(); return fromAccountType.isSavingsAccount() && toAccountType.isSavingsAccount();
} }
private boolean isCurrentToSavingsAccountTransfer(final PortfolioAccountType fromAccountType,
final PortfolioAccountType toAccountType) {
return fromAccountType.isCurrentAccount() && toAccountType.isSavingsAccount();
}
private boolean isSavingsToCurrentAccountTransfer(final PortfolioAccountType fromAccountType,
final PortfolioAccountType toAccountType) {
return fromAccountType.isSavingsAccount() && toAccountType.isCurrentAccount();
}
private boolean isCurrentToCurrentAccountTransfer(final PortfolioAccountType fromAccountType,
final PortfolioAccountType toAccountType) {
return fromAccountType.isCurrentAccount() && toAccountType.isCurrentAccount();
}
private boolean isCurrentToLoanAccountTransfer(final PortfolioAccountType fromAccountType,
final PortfolioAccountType toAccountType) {
return fromAccountType.isCurrentAccount() && toAccountType.isLoanAccount();
}
/**
* Withdraw for Current → Loan transfers. Loan domain service handles the loan-side GL,
* so current account side uses LIABILITY_TRANSFER as the counterpart (classic approach).
*/
private CurrentAccountTransaction performCurrentWithdrawalForLoan(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate) {
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
final BigDecimal available = balance.subtract(onHold);
if (!account.isAllowOverdraft() && amount.compareTo(available) > 0) {
throw new GeneralPlatformDomainRuleException("error.msg.current.account.insufficient.balance",
"Insufficient balance in current account " + account.getId()
+ ". Available: " + available + ", Requested: " + amount, account.getId());
}
final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, account.getOffice(), amount, txnDate, null);
final BigDecimal newBalance = balance.subtract(amount);
txn.setRunningBalanceDerived(newBalance);
currentAccountTransactionRepository.saveAndFlush(txn);
account.setAccountBalanceDerived(newBalance);
account.setTotalWithdrawalsDerived(
(account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount));
currentAccountRepository.save(account);
if (account.getProductId() != null && txn.getId() != null) {
final String currentTxnId = "CA" + txn.getId();
accountingProcessorHelper.createCashBasedJournalEntriesAndReversalsForSavings(account.getOffice(),
account.getCurrencyCode(), CashAccountsForSavings.SAVINGS_CONTROL.getValue(),
FinancialActivity.LIABILITY_TRANSFER.getValue(), account.getProductId(), null,
account.getId(), currentTxnId, txnDate, amount, false);
}
return txn;
}
/**
* Withdraw amount from a current account and save the transaction.
* Throws {@link GeneralPlatformDomainRuleException} if balance is insufficient.
*/
private CurrentAccountTransaction performCurrentWithdrawal(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) {
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final BigDecimal onHold = account.getOnHoldFundsDerived() != null ? account.getOnHoldFundsDerived() : BigDecimal.ZERO;
final BigDecimal available = balance.subtract(onHold);
if (!account.isAllowOverdraft() && amount.compareTo(available) > 0) {
throw new GeneralPlatformDomainRuleException("error.msg.current.account.insufficient.balance",
"Insufficient balance in current account " + account.getId()
+ ". Available: " + available + ", Requested: " + amount, account.getId());
}
final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, account.getOffice(), amount, txnDate, null);
final BigDecimal newBalance = balance.subtract(amount);
txn.setRunningBalanceDerived(newBalance);
currentAccountTransactionRepository.saveAndFlush(txn);
account.setAccountBalanceDerived(newBalance);
account.setTotalWithdrawalsDerived(
(account.getTotalWithdrawalsDerived() != null ? account.getTotalWithdrawalsDerived() : BigDecimal.ZERO).add(amount));
currentAccountRepository.save(account);
// Post journal entries: DR current account GL, CR counterpart GL — each with correct entity/account number
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, true, counterpartProductId, counterpartEntityId);
return txn;
}
/**
* Deposit amount into a current account and save the transaction.
*/
private CurrentAccountTransaction performCurrentDeposit(final CurrentAccount account, final BigDecimal amount,
final LocalDate txnDate, final Long counterpartProductId, final Long counterpartEntityId) {
final BigDecimal balance = account.getAccountBalanceDerived() != null ? account.getAccountBalanceDerived() : BigDecimal.ZERO;
final CurrentAccountTransaction txn = CurrentAccountTransaction.deposit(account, account.getOffice(), amount, txnDate, null);
final BigDecimal newBalance = balance.add(amount);
txn.setRunningBalanceDerived(newBalance);
currentAccountTransactionRepository.saveAndFlush(txn);
account.setAccountBalanceDerived(newBalance);
account.setTotalDepositsDerived(
(account.getTotalDepositsDerived() != null ? account.getTotalDepositsDerived() : BigDecimal.ZERO).add(amount));
currentAccountRepository.save(account);
// Post journal entries: DR counterpart GL, CR current account GL — each with correct entity/account number
postCurrentAccountTransferJournalEntries(account, txn, amount, txnDate, false, counterpartProductId, counterpartEntityId);
return txn;
}
/**
* Posts double-entry journal entries for a current account leg of an account transfer.
* <p>
* Current Product GL mappings are stored under {@code PortfolioProductType.SAVING}, so the
* savings JE helper resolves them correctly.
* <p>
* Transaction id is prefixed with "C" (e.g. {@code C57}) so the entries are NOT grouped with
* savings entries that share the same numeric id (those use prefix "S"). The helper only
* appends its own "S" prefix when the id is numeric; passing "C57" keeps it as-is.
*
* @param isWithdrawal {@code true} for the from-leg (DR control / CR liability transfer);
* {@code false} for the to-leg (DR liability transfer / CR control)
*/
/**
* Posts direct transfer journal entries between a current account and a counterpart savings/current account.
* Each entry carries the correct entity ID so the correct account number appears on each GL line.
*
* For withdrawal (current → savings):
* DR: current product GL (entity = current account)
* CR: counterpart product GL (entity = savings/current account)
*
* For deposit (savings → current):
* DR: counterpart product GL (entity = savings/current account)
* CR: current product GL (entity = current account)
*
* @param counterpartProductId savings/current product ID of the other account
* @param counterpartEntityId savings/current account ID of the other account
*/
private void postCurrentAccountTransferJournalEntries(final CurrentAccount account, final CurrentAccountTransaction txn,
final BigDecimal amount, final LocalDate txnDate, final boolean isWithdrawal,
final Long counterpartProductId, final Long counterpartEntityId) {
if (account.getProductId() == null || txn.getId() == null) {
return;
}
// Prefix "CA" so:
// 1. The helper does NOT re-prefix with "S" (avoids txn id collision with savings "S" prefix)
// 2. JournalEntryReadPlatformServiceImpl joins m_current_account (transaction_id LIKE 'CA%')
// so the correct current account number appears on the DR/CR entry.
final String currentTxnId = "CA" + txn.getId();
if (isWithdrawal) {
// DR: current account GL CR: counterpart (savings/current) GL
accountingProcessorHelper.createDirectTransferJournalEntriesForSavings(
account.getOffice(), account.getCurrencyCode(),
account.getProductId(), account.getId(),
counterpartProductId, counterpartEntityId,
currentTxnId, txnDate, amount);
} else {
// DR: counterpart (savings/current) GL CR: current account GL
accountingProcessorHelper.createDirectTransferJournalEntriesForSavings(
account.getOffice(), account.getCurrencyCode(),
counterpartProductId, counterpartEntityId,
account.getProductId(), account.getId(),
currentTxnId, txnDate, amount);
}
}
@Override @Override
@Transactional @Transactional
public CommandProcessingResult refundByTransfer(JsonCommand command) { public CommandProcessingResult refundByTransfer(JsonCommand command) {
@@ -43,12 +43,14 @@ public class PortfolioAccountReadPlatformServiceImpl implements PortfolioAccount
private final PortfolioSavingsAccountMapper savingsAccountMapper; private final PortfolioSavingsAccountMapper savingsAccountMapper;
private final PortfolioLoanAccountMapper loanAccountMapper; private final PortfolioLoanAccountMapper loanAccountMapper;
private final PortfolioLoanAccountRefundByTransferMapper accountRefundByTransferMapper; private final PortfolioLoanAccountRefundByTransferMapper accountRefundByTransferMapper;
private final PortfolioCurrentAccountMapper currentAccountMapper;
public PortfolioAccountReadPlatformServiceImpl(final JdbcTemplate jdbcTemplate, DatabaseSpecificSQLGenerator sqlGenerator) { public PortfolioAccountReadPlatformServiceImpl(final JdbcTemplate jdbcTemplate, DatabaseSpecificSQLGenerator sqlGenerator) {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
this.savingsAccountMapper = new PortfolioSavingsAccountMapper(); this.savingsAccountMapper = new PortfolioSavingsAccountMapper();
this.loanAccountMapper = new PortfolioLoanAccountMapper(); this.loanAccountMapper = new PortfolioLoanAccountMapper();
this.accountRefundByTransferMapper = new PortfolioLoanAccountRefundByTransferMapper(sqlGenerator); this.accountRefundByTransferMapper = new PortfolioLoanAccountRefundByTransferMapper(sqlGenerator);
this.currentAccountMapper = new PortfolioCurrentAccountMapper();
} }
@Override @Override
@@ -86,6 +88,15 @@ public class PortfolioAccountReadPlatformServiceImpl implements PortfolioAccount
accountData = this.jdbcTemplate.queryForObject(sql, this.savingsAccountMapper, sqlParams); accountData = this.jdbcTemplate.queryForObject(sql, this.savingsAccountMapper, sqlParams);
break; break;
case CURRENT:
sql = "select " + this.currentAccountMapper.schema() + " where ca.id = ?";
if (currencyCode != null) {
sql += " and ca.currency_code = ?";
sqlParams = new Object[] { accountId, currencyCode };
}
accountData = this.jdbcTemplate.queryForObject(sql, this.currentAccountMapper, sqlParams);
break;
} }
} catch (final EmptyResultDataAccessException e) { } catch (final EmptyResultDataAccessException e) {
throw new AccountTransferNotFoundException(accountId, e); throw new AccountTransferNotFoundException(accountId, e);
@@ -157,6 +168,23 @@ public class PortfolioAccountReadPlatformServiceImpl implements PortfolioAccount
accounts = this.jdbcTemplate.query(sql, this.savingsAccountMapper, sqlParams.toArray()); // NOSONAR accounts = this.jdbcTemplate.query(sql, this.savingsAccountMapper, sqlParams.toArray()); // NOSONAR
break; break;
case CURRENT:
sql = "select " + this.currentAccountMapper.schema() + " where ";
if (portfolioAccountDTO.getClientId() != null) {
sql += " ca.client_id = ? and ca.status_enum in (?) ";
sqlParams.add(portfolioAccountDTO.getClientId());
sqlParams.add(defaultAccountStatus);
} else {
sql += " ca.status_enum in (?) ";
sqlParams.add(defaultAccountStatus);
}
if (portfolioAccountDTO.getCurrencyCode() != null) {
sql += " and ca.currency_code = ?";
sqlParams.add(portfolioAccountDTO.getCurrencyCode());
}
accounts = this.jdbcTemplate.query(sql, this.currentAccountMapper, sqlParams.toArray()); // NOSONAR
break;
} }
return accounts; return accounts;
@@ -393,4 +421,62 @@ public class PortfolioAccountReadPlatformServiceImpl implements PortfolioAccount
return accountData; return accountData;
} }
private static final class PortfolioCurrentAccountMapper implements RowMapper<PortfolioAccountData> {
private final String schemaSql;
PortfolioCurrentAccountMapper() {
final StringBuilder sqlBuilder = new StringBuilder(400);
sqlBuilder.append("ca.id as id, ca.account_no as accountNo, ca.external_id as externalId, ");
sqlBuilder.append("c.id as clientId, c.display_name as clientName, ");
sqlBuilder.append("null as groupId, null as groupName, ");
sqlBuilder.append("cp.id as productId, cp.name as productName, ");
sqlBuilder.append("null as fieldOfficerId, null as fieldOfficerName, ");
sqlBuilder.append("ca.currency_code as currencyCode, ca.currency_digits as currencyDigits, ");
sqlBuilder.append("ca.currency_multiplesof as inMultiplesOf, ");
sqlBuilder.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
sqlBuilder.append("curr.display_symbol as currencyDisplaySymbol ");
sqlBuilder.append("from m_current_account ca ");
sqlBuilder.append("join m_current_product cp ON ca.product_id = cp.id ");
sqlBuilder.append("join m_currency curr on curr.code = ca.currency_code ");
sqlBuilder.append("left join m_client c ON c.id = ca.client_id ");
this.schemaSql = sqlBuilder.toString();
}
public String schema() {
return this.schemaSql;
}
@Override
public PortfolioAccountData mapRow(final ResultSet rs, @SuppressWarnings("unused") final int rowNum) throws SQLException {
final Long id = rs.getLong("id");
final String accountNo = rs.getString("accountNo");
final String externalId = rs.getString("externalId");
final Long groupId = JdbcSupport.getLong(rs, "groupId");
final String groupName = rs.getString("groupName");
final Long clientId = JdbcSupport.getLong(rs, "clientId");
final String clientName = rs.getString("clientName");
final Long productId = rs.getLong("productId");
final String productName = rs.getString("productName");
final Long fieldOfficerId = JdbcSupport.getLong(rs, "fieldOfficerId");
final String fieldOfficerName = rs.getString("fieldOfficerName");
final String currencyCode = rs.getString("currencyCode");
final String currencyName = rs.getString("currencyName");
final String currencyNameCode = rs.getString("currencyNameCode");
final String currencyDisplaySymbol = rs.getString("currencyDisplaySymbol");
final Integer currencyDigits = JdbcSupport.getInteger(rs, "currencyDigits");
final Integer inMultiplesOf = JdbcSupport.getInteger(rs, "inMultiplesOf");
final CurrencyData currency = new CurrencyData(currencyCode, currencyName, currencyDigits, inMultiplesOf,
currencyDisplaySymbol, currencyNameCode);
return new PortfolioAccountData(id, accountNo, externalId, groupId, groupName, clientId, clientName, productId, productName,
fieldOfficerId, fieldOfficerName, currency);
}
}
} }
@@ -109,20 +109,21 @@ public class StandingInstructionReadPlatformServiceImpl implements StandingInstr
final EnumOptionData loanAccountType = accountType(PortfolioAccountType.LOAN); final EnumOptionData loanAccountType = accountType(PortfolioAccountType.LOAN);
final EnumOptionData savingsAccountType = accountType(PortfolioAccountType.SAVINGS); final EnumOptionData savingsAccountType = accountType(PortfolioAccountType.SAVINGS);
final EnumOptionData currentAccountType = accountType(PortfolioAccountType.CURRENT);
final Integer mostRelevantFromAccountType = fromAccountType; final Integer mostRelevantFromAccountType = fromAccountType;
Collection<EnumOptionData> fromAccountTypeOptions = null; Collection<EnumOptionData> fromAccountTypeOptions = null;
Collection<EnumOptionData> toAccountTypeOptions = null; Collection<EnumOptionData> toAccountTypeOptions = null;
if (accountTransferType.isAccountTransfer()) { if (accountTransferType.isAccountTransfer()) {
fromAccountTypeOptions = Arrays.asList(savingsAccountType); fromAccountTypeOptions = Arrays.asList(savingsAccountType, currentAccountType);
toAccountTypeOptions = Arrays.asList(savingsAccountType); toAccountTypeOptions = Arrays.asList(savingsAccountType, currentAccountType);
} else if (accountTransferType.isLoanRepayment()) { } else if (accountTransferType.isLoanRepayment()) {
fromAccountTypeOptions = Arrays.asList(savingsAccountType); fromAccountTypeOptions = Arrays.asList(savingsAccountType, currentAccountType);
toAccountTypeOptions = Arrays.asList(loanAccountType); toAccountTypeOptions = Arrays.asList(loanAccountType);
} else { } else {
fromAccountTypeOptions = Arrays.asList(savingsAccountType, loanAccountType); fromAccountTypeOptions = Arrays.asList(savingsAccountType, currentAccountType, loanAccountType);
toAccountTypeOptions = Arrays.asList(loanAccountType, savingsAccountType); toAccountTypeOptions = Arrays.asList(loanAccountType, savingsAccountType, currentAccountType);
} }
final Integer mostRelevantToAccountType = toAccountType; final Integer mostRelevantToAccountType = toAccountType;
@@ -152,6 +153,8 @@ public class StandingInstructionReadPlatformServiceImpl implements StandingInstr
Integer accountType; Integer accountType;
if (mostRelevantFromAccountType == 1) { if (mostRelevantFromAccountType == 1) {
accountType = PortfolioAccountType.LOAN.getValue(); accountType = PortfolioAccountType.LOAN.getValue();
} else if (mostRelevantFromAccountType == 3) {
accountType = PortfolioAccountType.CURRENT.getValue();
} else { } else {
accountType = PortfolioAccountType.SAVINGS.getValue(); accountType = PortfolioAccountType.SAVINGS.getValue();
} }
@@ -301,6 +304,9 @@ public class StandingInstructionReadPlatformServiceImpl implements StandingInstr
} else if (accountType.isLoanAccount()) { } else if (accountType.isLoanAccount()) {
sqlBuilder.append(" fromloanacc.id=? "); sqlBuilder.append(" fromloanacc.id=? ");
paramObj.add(standingInstructionDTO.fromAccount()); paramObj.add(standingInstructionDTO.fromAccount());
} else if (accountType.isCurrentAccount()) {
sqlBuilder.append(" fromcuracc.id=? ");
paramObj.add(standingInstructionDTO.fromAccount());
} }
addAndCaluse = true; addAndCaluse = true;
} }
@@ -387,7 +393,11 @@ public class StandingInstructionReadPlatformServiceImpl implements StandingInstr
sqlBuilder.append("tosavacc.id as toSavingsAccountId, tosavacc.account_no as toSavingsAccountNo,"); sqlBuilder.append("tosavacc.id as toSavingsAccountId, tosavacc.account_no as toSavingsAccountNo,");
sqlBuilder.append("tosp.id as toProductId, tosp.name as toProductName, "); sqlBuilder.append("tosp.id as toProductId, tosp.name as toProductName, ");
sqlBuilder.append("toloanacc.id as toLoanAccountId, toloanacc.account_no as toLoanAccountNo, "); sqlBuilder.append("toloanacc.id as toLoanAccountId, toloanacc.account_no as toLoanAccountNo, ");
sqlBuilder.append("tolp.id as toLoanProductId, tolp.name as toLoanProductName "); sqlBuilder.append("tolp.id as toLoanProductId, tolp.name as toLoanProductName, ");
sqlBuilder.append("fromcuracc.id as fromCurrentAccountId, fromcuracc.account_no as fromCurrentAccountNo, ");
sqlBuilder.append("fromcp.id as fromCurrentProductId, fromcp.name as fromCurrentProductName, ");
sqlBuilder.append("tocuracc.id as toCurrentAccountId, tocuracc.account_no as toCurrentAccountNo, ");
sqlBuilder.append("tocp.id as toCurrentProductId, tocp.name as toCurrentProductName ");
sqlBuilder.append(" FROM m_account_transfer_standing_instructions atsi "); sqlBuilder.append(" FROM m_account_transfer_standing_instructions atsi ");
sqlBuilder.append("join m_account_transfer_details atd on atd.id = atsi.account_transfer_details_id "); sqlBuilder.append("join m_account_transfer_details atd on atd.id = atsi.account_transfer_details_id ");
sqlBuilder.append("join m_office fromoff on fromoff.id = atd.from_office_id "); sqlBuilder.append("join m_office fromoff on fromoff.id = atd.from_office_id ");
@@ -402,6 +412,10 @@ public class StandingInstructionReadPlatformServiceImpl implements StandingInstr
sqlBuilder.append("left join m_savings_product tosp ON tosavacc.product_id = tosp.id "); sqlBuilder.append("left join m_savings_product tosp ON tosavacc.product_id = tosp.id ");
sqlBuilder.append("left join m_loan toloanacc on toloanacc.id = atd.to_loan_account_id "); sqlBuilder.append("left join m_loan toloanacc on toloanacc.id = atd.to_loan_account_id ");
sqlBuilder.append("left join m_product_loan tolp ON toloanacc.product_id = tolp.id "); sqlBuilder.append("left join m_product_loan tolp ON toloanacc.product_id = tolp.id ");
sqlBuilder.append("left join m_current_account fromcuracc on fromcuracc.id = atd.from_current_account_id ");
sqlBuilder.append("left join m_current_product fromcp ON fromcuracc.product_id = fromcp.id ");
sqlBuilder.append("left join m_current_account tocuracc on tocuracc.id = atd.to_current_account_id ");
sqlBuilder.append("left join m_current_product tocp ON tocuracc.product_id = tocp.id ");
this.schemaSql = sqlBuilder.toString(); this.schemaSql = sqlBuilder.toString();
} }
@@ -489,6 +503,16 @@ public class StandingInstructionReadPlatformServiceImpl implements StandingInstr
fromAccount = new PortfolioAccountData(fromLoanAccountId, fromLoanAccountNo, null, null, null, null, null, fromAccount = new PortfolioAccountData(fromLoanAccountId, fromLoanAccountNo, null, null, null, null, null,
fromLoanProductId, fromLoanProductName, null, null, null); fromLoanProductId, fromLoanProductName, null, null, null);
fromAccountType = accountType(PortfolioAccountType.LOAN); fromAccountType = accountType(PortfolioAccountType.LOAN);
} else {
final Long fromCurrentAccountId = JdbcSupport.getLong(rs, "fromCurrentAccountId");
if (fromCurrentAccountId != null) {
final String fromCurrentAccountNo = rs.getString("fromCurrentAccountNo");
final Long fromCurrentProductId = JdbcSupport.getLong(rs, "fromCurrentProductId");
final String fromCurrentProductName = rs.getString("fromCurrentProductName");
fromAccount = new PortfolioAccountData(fromCurrentAccountId, fromCurrentAccountNo, null, null, null, null, null,
fromCurrentProductId, fromCurrentProductName, null, null, null);
fromAccountType = accountType(PortfolioAccountType.CURRENT);
}
} }
PortfolioAccountData toAccount = null; PortfolioAccountData toAccount = null;
@@ -510,6 +534,16 @@ public class StandingInstructionReadPlatformServiceImpl implements StandingInstr
toAccount = new PortfolioAccountData(toLoanAccountId, toLoanAccountNo, null, null, null, null, null, toLoanProductId, toAccount = new PortfolioAccountData(toLoanAccountId, toLoanAccountNo, null, null, null, null, null, toLoanProductId,
toLoanProductName, null, null, null); toLoanProductName, null, null, null);
toAccountType = accountType(PortfolioAccountType.LOAN); toAccountType = accountType(PortfolioAccountType.LOAN);
} else {
final Long toCurrentAccountId = JdbcSupport.getLong(rs, "toCurrentAccountId");
if (toCurrentAccountId != null) {
final String toCurrentAccountNo = rs.getString("toCurrentAccountNo");
final Long toCurrentProductId = JdbcSupport.getLong(rs, "toCurrentProductId");
final String toCurrentProductName = rs.getString("toCurrentProductName");
toAccount = new PortfolioAccountData(toCurrentAccountId, toCurrentAccountNo, null, null, null, null, null,
toCurrentProductId, toCurrentProductName, null, null, null);
toAccountType = accountType(PortfolioAccountType.CURRENT);
}
} }
return StandingInstructionData.instance(id, accountDetailId, name, fromOffice, toOffice, fromClient, toClient, fromAccountType, return StandingInstructionData.instance(id, accountDetailId, name, fromOffice, toOffice, fromClient, toClient, fromAccountType,
@@ -82,11 +82,25 @@ public class StandingInstructionWritePlatformServiceImpl implements StandingInst
this.accountTransferDetailRepository.saveAndFlush(standingInstruction); this.accountTransferDetailRepository.saveAndFlush(standingInstruction);
standingInstructionId = standingInstruction.accountTransferStandingInstruction().getId(); standingInstructionId = standingInstruction.accountTransferStandingInstruction().getId();
} else if (isLoanToSavingsAccountTransfer(fromAccountType, toAccountType)) { } else if (isLoanToSavingsAccountTransfer(fromAccountType, toAccountType)) {
final AccountTransferDetails standingInstruction = this.standingInstructionAssembler.assembleLoanToSavingsTransfer(command); final AccountTransferDetails standingInstruction = this.standingInstructionAssembler.assembleLoanToSavingsTransfer(command);
this.accountTransferDetailRepository.saveAndFlush(standingInstruction); this.accountTransferDetailRepository.saveAndFlush(standingInstruction);
standingInstructionId = standingInstruction.accountTransferStandingInstruction().getId(); standingInstructionId = standingInstruction.accountTransferStandingInstruction().getId();
} else if (isCurrentToCurrentAccountTransfer(fromAccountType, toAccountType)) {
final AccountTransferDetails standingInstruction = this.standingInstructionAssembler.assembleCurrentToCurrentTransfer(command);
this.accountTransferDetailRepository.saveAndFlush(standingInstruction);
standingInstructionId = standingInstruction.accountTransferStandingInstruction().getId();
} else if (isCurrentToSavingsAccountTransfer(fromAccountType, toAccountType)) {
final AccountTransferDetails standingInstruction = this.standingInstructionAssembler.assembleCurrentToSavingsTransfer(command);
this.accountTransferDetailRepository.saveAndFlush(standingInstruction);
standingInstructionId = standingInstruction.accountTransferStandingInstruction().getId();
} else if (isSavingsToCurrentAccountTransfer(fromAccountType, toAccountType)) {
final AccountTransferDetails standingInstruction = this.standingInstructionAssembler.assembleSavingsToCurrentTransfer(command);
this.accountTransferDetailRepository.saveAndFlush(standingInstruction);
standingInstructionId = standingInstruction.accountTransferStandingInstruction().getId();
} else if (isCurrentToLoanAccountTransfer(fromAccountType, toAccountType)) {
final AccountTransferDetails standingInstruction = this.standingInstructionAssembler.assembleCurrentToLoanTransfer(command);
this.accountTransferDetailRepository.saveAndFlush(standingInstruction);
standingInstructionId = standingInstruction.accountTransferStandingInstruction().getId();
} }
} catch (final JpaSystemException | DataIntegrityViolationException dve) { } catch (final JpaSystemException | DataIntegrityViolationException dve) {
final Throwable throwable = dve.getMostSpecificCause(); final Throwable throwable = dve.getMostSpecificCause();
@@ -122,6 +136,26 @@ public class StandingInstructionWritePlatformServiceImpl implements StandingInst
return fromAccountType.isSavingsAccount() && toAccountType.isSavingsAccount(); return fromAccountType.isSavingsAccount() && toAccountType.isSavingsAccount();
} }
private boolean isCurrentToCurrentAccountTransfer(final PortfolioAccountType fromAccountType,
final PortfolioAccountType toAccountType) {
return fromAccountType.isCurrentAccount() && toAccountType.isCurrentAccount();
}
private boolean isCurrentToSavingsAccountTransfer(final PortfolioAccountType fromAccountType,
final PortfolioAccountType toAccountType) {
return fromAccountType.isCurrentAccount() && toAccountType.isSavingsAccount();
}
private boolean isSavingsToCurrentAccountTransfer(final PortfolioAccountType fromAccountType,
final PortfolioAccountType toAccountType) {
return fromAccountType.isSavingsAccount() && toAccountType.isCurrentAccount();
}
private boolean isCurrentToLoanAccountTransfer(final PortfolioAccountType fromAccountType,
final PortfolioAccountType toAccountType) {
return fromAccountType.isCurrentAccount() && toAccountType.isLoanAccount();
}
@Override @Override
public CommandProcessingResult update(final Long id, final JsonCommand command) { public CommandProcessingResult update(final Long id, final JsonCommand command) {
this.standingInstructionDataValidator.validateForUpdate(command); this.standingInstructionDataValidator.validateForUpdate(command);
@@ -18,6 +18,7 @@
*/ */
package org.apache.fineract.portfolio.account.starter; package org.apache.fineract.portfolio.account.starter;
import org.apache.fineract.accounting.journalentry.service.AccountingProcessorHelper;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.config.FineractProperties; import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
@@ -26,6 +27,8 @@ import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecific
import org.apache.fineract.infrastructure.security.service.SqlValidator; import org.apache.fineract.infrastructure.security.service.SqlValidator;
import org.apache.fineract.infrastructure.security.utils.ColumnValidator; import org.apache.fineract.infrastructure.security.utils.ColumnValidator;
import org.apache.fineract.organisation.office.service.OfficeReadPlatformService; import org.apache.fineract.organisation.office.service.OfficeReadPlatformService;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountRepository;
import org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransactionRepository;
import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator; import org.apache.fineract.portfolio.account.data.AccountTransfersDataValidator;
import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator; import org.apache.fineract.portfolio.account.data.StandingInstructionDataValidator;
import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler; import org.apache.fineract.portfolio.account.domain.AccountTransferAssembler;
@@ -91,11 +94,14 @@ public class AccountConfiguration {
LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService, LoanAccountDomainService loanAccountDomainService, SavingsAccountWritePlatformService savingsAccountWritePlatformService,
AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService, AccountTransferDetailRepository accountTransferDetailRepository, LoanReadPlatformService loanReadPlatformService,
GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory, GSIMRepositoy gsimRepository, ConfigurationDomainService configurationDomainService, ExternalIdFactory externalIdFactory,
FineractProperties fineractProperties) { FineractProperties fineractProperties, CurrentAccountRepository currentAccountRepository,
CurrentAccountTransactionRepository currentAccountTransactionRepository,
AccountingProcessorHelper accountingProcessorHelper) {
return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler, return new AccountTransfersWritePlatformServiceImpl(accountTransfersDataValidator, accountTransferAssembler,
accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler, accountTransferRepository, savingsAccountAssembler, savingsAccountDomainService, loanAccountAssembler,
loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService, loanAccountDomainService, savingsAccountWritePlatformService, accountTransferDetailRepository, loanReadPlatformService,
gsimRepository, configurationDomainService, externalIdFactory, fineractProperties); gsimRepository, configurationDomainService, externalIdFactory, fineractProperties,
currentAccountRepository, currentAccountTransactionRepository, accountingProcessorHelper);
} }
@Bean @Bean
@@ -0,0 +1,276 @@
package org.apache.fineract.portfolio.cashexchange.api;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchange;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeDetail;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeDetailRepository;
import org.apache.fineract.portfolio.cashexchange.domain.CashExchangeRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Path("/v1/cashexchange")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class CashExchangeApiResource {
private final CashExchangeRepository repository;
private final CashExchangeDetailRepository detailRepository;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final Gson gson = new Gson();
@POST
@Transactional
public String create(String body) {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final Long accountId = json.has("accountId") && !json.get("accountId").isJsonNull() ? json.get("accountId").getAsLong() : null;
final String accountNo = json.has("accountNo") && !json.get("accountNo").isJsonNull() ? json.get("accountNo").getAsString()
: null;
final Long clientId = json.has("clientId") && !json.get("clientId").isJsonNull() ? json.get("clientId").getAsLong() : null;
final LocalDate exchangeDate = json.has("exchangeDate") && !json.get("exchangeDate").isJsonNull()
? LocalDate.parse(json.get("exchangeDate").getAsString())
: null;
final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null;
final String customerIdProof = json.has("customerIdProof") && !json.get("customerIdProof").isJsonNull()
? json.get("customerIdProof").getAsString() : null;
final JsonArray received = json.has("received") && json.get("received").isJsonArray() ? json.getAsJsonArray("received")
: new JsonArray();
final JsonArray given = json.has("given") && json.get("given").isJsonArray() ? json.getAsJsonArray("given") : new JsonArray();
final BigDecimal receivedTotal = sumTotal(received);
final BigDecimal givenTotal = sumTotal(given);
if (!hasPositiveCount(received) || !hasPositiveCount(given)) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.empty",
"Both received and given denominations are required.");
}
if (receivedTotal.compareTo(givenTotal) != 0) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.mismatch",
"Received total (" + receivedTotal + ") must equal given total (" + givenTotal + ").");
}
if (receivedTotal.compareTo(BigDecimal.ZERO) <= 0) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.empty",
"Both received and given denominations are required.");
}
CashExchange header = new CashExchange();
header.setAccountId(accountId);
header.setAccountNo(accountNo);
header.setClientId(clientId);
header.setExchangeAmount(receivedTotal);
header.setExchangeDate(exchangeDate);
header.setNote(note);
header.setCustomerIdProof(customerIdProof);
header.setIsReversed(false);
header.setCreatedAt(LocalDateTime.now());
try {
AppUser user = this.context.authenticatedUser();
if (user != null) {
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
// Auto-detect cashier linked to this user
try {
Long cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
Long.class, user.getId());
header.setCashierId(cashierId);
} catch (Exception ignored) {}
}
} catch (Exception e) {
// resolving the current user must never block recording the exchange
}
// Validate that cashier has enough of each denomination being GIVEN to the customer
if (header.getCashierId() != null) {
final java.util.Map<Long, Long> stock = denominationBreakdownWriter.getCashierNetPosition(header.getCashierId());
final java.util.List<String> shortages = new java.util.ArrayList<>();
for (JsonElement el : given) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) continue;
if (!item.has("denominationId") || item.get("denominationId").isJsonNull()) continue;
long denomId = item.get("denominationId").getAsLong();
long available = stock.getOrDefault(denomId, 0L);
if (count > available) {
int denomValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt() : 0;
shortages.add("₹" + denomValue + " (need " + count + ", available " + available + ")");
}
}
if (!shortages.isEmpty()) {
throw new PlatformDataIntegrityException(
"error.msg.cashier.denomination.insufficient",
"Insufficient denomination stock for exchange — cannot give: " + String.join("; ", shortages));
}
}
CashExchange saved = repository.saveAndFlush(header);
saveDetails(saved.getId(), "RECEIVED", received);
saveDetails(saved.getId(), "GIVEN", given);
JsonObject result = new JsonObject();
result.addProperty("resourceId", saved.getId());
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
public String reverse(@PathParam("id") Long id, String body) {
CashExchange exchange = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id));
if (Boolean.TRUE.equals(exchange.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashexchange.already.reversed",
"Cash exchange " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") && !json.get("reason").isJsonNull() ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {}
exchange.setIsReversed(true);
exchange.setReversedAt(LocalDateTime.now());
exchange.setReverseReason(reason);
try {
AppUser user = this.context.authenticatedUser();
if (user != null) exchange.setReversedBy(user.getId());
} catch (Exception ignored) {}
repository.save(exchange);
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
public String getAll(@QueryParam("accountId") Long accountId) {
List<CashExchange> list = accountId != null ? repository.findByAccountIdOrderByCreatedAtDesc(accountId)
: repository.findAllByOrderByCreatedAtDesc();
return gson.toJson(list.stream().map(this::toMap).collect(Collectors.toList()));
}
@GET
@Path("{id}")
public String getOne(@PathParam("id") Long id) {
CashExchange header = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Cash exchange not found: " + id));
Map<String, Object> map = toMap(header);
List<Map<String, Object>> details = detailRepository.findByExchangeId(id).stream().map(this::detailToMap)
.collect(Collectors.toList());
map.put("details", details);
return gson.toJson(map);
}
private BigDecimal sumTotal(JsonArray items) {
BigDecimal total = BigDecimal.ZERO;
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt()
: 0;
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count > 0) {
total = total.add(BigDecimal.valueOf((long) value * count));
}
}
return total;
}
private boolean hasPositiveCount(JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count > 0) {
return true;
}
}
return false;
}
private void saveDetails(Long exchangeId, String direction, JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) {
continue;
}
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt()
: 0;
Long denominationId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
? item.get("denominationId").getAsLong()
: null;
CashExchangeDetail detail = new CashExchangeDetail();
detail.setExchangeId(exchangeId);
detail.setDirection(direction);
detail.setDenominationId(denominationId);
detail.setDenominationValue(value);
detail.setCount(count);
detail.setTotalAmount(BigDecimal.valueOf((long) value * count));
detailRepository.save(detail);
}
}
private Map<String, Object> toMap(CashExchange e) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", e.getId());
map.put("cashierId", e.getCashierId());
map.put("accountId", e.getAccountId());
map.put("accountNo", e.getAccountNo());
map.put("clientId", e.getClientId());
map.put("customerIdProof", e.getCustomerIdProof());
map.put("exchangeAmount", e.getExchangeAmount());
map.put("exchangeDate", e.getExchangeDate() != null ? e.getExchangeDate().toString() : null);
map.put("note", e.getNote());
map.put("performedBy", e.getPerformedBy());
map.put("performedByUsername", e.getPerformedByUsername());
map.put("createdAt", e.getCreatedAt() != null ? e.getCreatedAt().toString() : null);
map.put("isReversed", Boolean.TRUE.equals(e.getIsReversed()));
map.put("reversedBy", e.getReversedBy());
map.put("reversedAt", e.getReversedAt() != null ? e.getReversedAt().toString() : null);
map.put("reverseReason", e.getReverseReason());
return map;
}
private Map<String, Object> detailToMap(CashExchangeDetail d) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", d.getId());
map.put("direction", d.getDirection());
map.put("denominationId", d.getDenominationId());
map.put("denominationValue", d.getDenominationValue());
map.put("count", d.getCount());
map.put("totalAmount", d.getTotalAmount());
return map;
}
}
@@ -0,0 +1,75 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.
*/
package org.apache.fineract.portfolio.cashexchange.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_exchange")
@Getter
@Setter
@NoArgsConstructor
public class CashExchange {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "account_id")
private Long accountId;
@Column(name = "account_no", length = 50)
private String accountNo;
@Column(name = "client_id")
private Long clientId;
@Column(name = "exchange_amount", nullable = false)
private BigDecimal exchangeAmount;
@Column(name = "exchange_date")
private LocalDate exchangeDate;
@Column(name = "note", length = 500)
private String note;
@Column(name = "cashier_id")
private Long cashierId;
@Column(name = "customer_id_proof", length = 50)
private String customerIdProof;
@Column(name = "performed_by")
private Long performedBy;
@Column(name = "performed_by_username", length = 100)
private String performedByUsername;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "is_reversed")
private Boolean isReversed;
@Column(name = "reversed_by")
private Long reversedBy;
@Column(name = "reversed_at")
private LocalDateTime reversedAt;
@Column(name = "reverse_reason", length = 500)
private String reverseReason;
}
@@ -0,0 +1,46 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.
*/
package org.apache.fineract.portfolio.cashexchange.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_exchange_detail")
@Getter
@Setter
@NoArgsConstructor
public class CashExchangeDetail {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "exchange_id", nullable = false)
private Long exchangeId;
@Column(name = "direction", nullable = false, length = 10)
private String direction;
@Column(name = "denomination_id")
private Long denominationId;
@Column(name = "denomination_value", nullable = false)
private Integer denominationValue;
@Column(name = "count", nullable = false)
private Integer count;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
}
@@ -0,0 +1,10 @@
package org.apache.fineract.portfolio.cashexchange.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashExchangeDetailRepository extends JpaRepository<CashExchangeDetail, Long> {
List<CashExchangeDetail> findByExchangeId(Long exchangeId);
}
@@ -0,0 +1,11 @@
package org.apache.fineract.portfolio.cashexchange.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashExchangeRepository extends JpaRepository<CashExchange, Long> {
List<CashExchange> findAllByOrderByCreatedAtDesc();
List<CashExchange> findByAccountIdOrderByCreatedAtDesc(Long accountId);
}
@@ -0,0 +1,224 @@
package org.apache.fineract.portfolio.cashintercashier.api;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashier;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetail;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierDetailRepository;
import org.apache.fineract.portfolio.cashintercashier.domain.CashInterCashierRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Path("/v1/cashintercashier")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class CashInterCashierApiResource {
private final CashInterCashierRepository repository;
private final CashInterCashierDetailRepository detailRepository;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
@POST
@Transactional
public String create(String body) {
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHINTERCASHIER");
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final Long tellerId = json.has("tellerId") && !json.get("tellerId").isJsonNull()
? json.get("tellerId").getAsLong() : null;
final Long fromCashierId = json.has("fromCashierId") && !json.get("fromCashierId").isJsonNull()
? json.get("fromCashierId").getAsLong() : null;
final Long toCashierId = json.has("toCashierId") && !json.get("toCashierId").isJsonNull()
? json.get("toCashierId").getAsLong() : null;
if (tellerId == null || fromCashierId == null || toCashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.parties.required",
"tellerId, fromCashierId and toCashierId are required.");
}
if (fromCashierId.equals(toCashierId)) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.same.cashier",
"From-cashier and To-cashier cannot be the same.");
}
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
? LocalDate.parse(json.get("transferDate").getAsString()) : LocalDate.now();
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull()
? json.get("fromParty").getAsString() : null;
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull()
? json.get("toParty").getAsString() : null;
final String note = json.has("note") && !json.get("note").isJsonNull()
? json.get("note").getAsString() : null;
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
? json.getAsJsonArray("denominations") : new JsonArray();
if (!hasPositiveCount(denominations)) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.empty",
"At least one denomination with a positive count is required.");
}
final BigDecimal total = sumTotal(denominations);
CashInterCashier header = new CashInterCashier();
header.setTransferDate(transferDate);
header.setTellerId(tellerId);
header.setFromCashierId(fromCashierId);
header.setToCashierId(toCashierId);
header.setFromParty(fromParty);
header.setToParty(toParty);
header.setTransferAmount(total);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now());
header.setIsReversed(false);
try {
AppUser user = this.context.authenticatedUser();
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
} catch (Exception ignored) {}
CashInterCashier saved = repository.saveAndFlush(header);
saveDetails(saved.getId(), denominations);
JsonObject result = new JsonObject();
result.addProperty("resourceId", saved.getId());
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
@Transactional
public String reverse(@PathParam("id") Long id, String body) {
context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHINTERCASHIER");
CashInterCashier record = repository.findById(id)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashintercashier.not.found",
"Inter-cashier transfer not found: " + id));
if (Boolean.TRUE.equals(record.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashintercashier.already.reversed",
"Inter-cashier transfer " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {}
AppUser user = null;
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {}
record.setIsReversed(true);
record.setReversedAt(LocalDateTime.now());
record.setReverseReason(reason);
if (user != null) record.setReversedBy(user.getId());
repository.save(record);
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
public String getAll() {
return gson.toJson(repository.findAllByOrderByCreatedAtDesc().stream()
.map(this::toMap).collect(Collectors.toList()));
}
@GET
@Path("{id}")
public String getOne(@PathParam("id") Long id) {
CashInterCashier header = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Inter-cashier transfer not found: " + id));
Map<String, Object> map = toMap(header);
map.put("details", detailRepository.findByInterCashierId(id).stream()
.map(this::detailToMap).collect(Collectors.toList()));
return gson.toJson(map);
}
private BigDecimal sumTotal(JsonArray items) {
BigDecimal total = BigDecimal.ZERO;
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int value = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
int count = item.has("count") ? item.get("count").getAsInt() : 0;
if (count > 0) total = total.add(BigDecimal.valueOf((long) value * count));
}
return total;
}
private boolean hasPositiveCount(JsonArray items) {
for (JsonElement el : items) {
if (el.getAsJsonObject().get("count").getAsInt() > 0) return true;
}
return false;
}
private void saveDetails(Long interCashierId, JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") ? item.get("count").getAsInt() : 0;
if (count <= 0) continue;
int value = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
Long denomId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
? item.get("denominationId").getAsLong() : null;
CashInterCashierDetail d = new CashInterCashierDetail();
d.setInterCashierId(interCashierId);
d.setDenominationId(denomId);
d.setDenominationValue(value);
d.setCount(count);
d.setTotalAmount(BigDecimal.valueOf((long) value * count));
detailRepository.save(d);
}
}
private Map<String, Object> toMap(CashInterCashier e) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", e.getId());
m.put("transferDate", e.getTransferDate() != null ? e.getTransferDate().toString() : null);
m.put("tellerId", e.getTellerId());
m.put("fromCashierId", e.getFromCashierId());
m.put("toCashierId", e.getToCashierId());
m.put("fromParty", e.getFromParty());
m.put("toParty", e.getToParty());
m.put("transferAmount", e.getTransferAmount());
m.put("note", e.getNote());
m.put("performedByUsername", e.getPerformedByUsername());
m.put("createdAt", e.getCreatedAt() != null ? e.getCreatedAt().toString() : null);
m.put("isReversed", e.getIsReversed());
return m;
}
private Map<String, Object> detailToMap(CashInterCashierDetail d) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", d.getId());
m.put("denominationId", d.getDenominationId());
m.put("denominationValue", d.getDenominationValue());
m.put("count", d.getCount());
m.put("totalAmount", d.getTotalAmount());
return m;
}
}
@@ -0,0 +1,71 @@
package org.apache.fineract.portfolio.cashintercashier.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_inter_cashier")
@Getter
@Setter
@NoArgsConstructor
public class CashInterCashier {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "transfer_date")
private LocalDate transferDate;
@Column(name = "teller_id")
private Long tellerId;
@Column(name = "from_cashier_id")
private Long fromCashierId;
@Column(name = "to_cashier_id")
private Long toCashierId;
@Column(name = "from_party", length = 100)
private String fromParty;
@Column(name = "to_party", length = 100)
private String toParty;
@Column(name = "transfer_amount", nullable = false)
private BigDecimal transferAmount;
@Column(name = "note", length = 500)
private String note;
@Column(name = "performed_by")
private Long performedBy;
@Column(name = "performed_by_username", length = 100)
private String performedByUsername;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "is_reversed")
private Boolean isReversed = false;
@Column(name = "reversed_by")
private Long reversedBy;
@Column(name = "reversed_at")
private LocalDateTime reversedAt;
@Column(name = "reverse_reason", length = 500)
private String reverseReason;
}
@@ -0,0 +1,39 @@
package org.apache.fineract.portfolio.cashintercashier.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_inter_cashier_detail")
@Getter
@Setter
@NoArgsConstructor
public class CashInterCashierDetail {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "inter_cashier_id", nullable = false)
private Long interCashierId;
@Column(name = "denomination_id")
private Long denominationId;
@Column(name = "denomination_value", nullable = false)
private Integer denominationValue;
@Column(name = "count", nullable = false)
private Integer count;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
}
@@ -0,0 +1,10 @@
package org.apache.fineract.portfolio.cashintercashier.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashInterCashierDetailRepository extends JpaRepository<CashInterCashierDetail, Long> {
List<CashInterCashierDetail> findByInterCashierId(Long interCashierId);
}
@@ -0,0 +1,11 @@
package org.apache.fineract.portfolio.cashintercashier.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashInterCashierRepository extends JpaRepository<CashInterCashier, Long> {
List<CashInterCashier> findAllByOrderByCreatedAtDesc();
List<CashInterCashier> findByTellerIdOrderByCreatedAtDesc(Long tellerId);
}
@@ -0,0 +1,387 @@
package org.apache.fineract.portfolio.cashreconciliation.api;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliation;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetail;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationDetailRepository;
import org.apache.fineract.portfolio.cashreconciliation.domain.CashReconciliationRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Path("/v1/cashreconciliation")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class CashReconciliationApiResource {
private final CashReconciliationRepository repository;
private final CashReconciliationDetailRepository detailRepository;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
/**
* GET /cashreconciliation/expected?cashierId=X&asOfDate=yyyy-MM-dd
* Returns denomination-wise expected balance for a cashier.
* Computation: allocations (m_cash_transfer_detail where to_cashier_id=X)
* + inter-cashier-in (m_cash_inter_cashier_detail where to_cashier_id=X)
* - settlements (m_cash_settlement_detail where from_cashier_id=X)
* - inter-cashier-out (m_cash_inter_cashier_detail where from_cashier_id=X)
*/
@GET
@Path("expected")
public String getExpected(@QueryParam("cashierId") Long cashierId, @QueryParam("asOfDate") String asOfDate) {
context.authenticatedUser().validateHasPermissionTo("READ_CASHRECONCILIATION");
if (cashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId query param is required.");
}
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString();
// In: vault → cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
final String sqlIn1 = "SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count "
+ "FROM m_cash_transfer_detail ctd "
+ "JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id "
+ "WHERE ct.to_cashier_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false) "
+ "AND ct.transfer_date <= CAST(? AS DATE) "
+ "GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind";
// In: inter-cashier in
final String sqlIn2 = "SELECT icd.denomination_id, icd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(icd.count) AS total_count "
+ "FROM m_cash_inter_cashier_detail icd "
+ "JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = icd.denomination_id "
+ "WHERE ic.to_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false) "
+ "AND ic.transfer_date <= CAST(? AS DATE) "
+ "GROUP BY icd.denomination_id, icd.denomination_value, dm.kind";
// In: customer deposits received by this cashier
final String sqlIn3 = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(std.count) AS total_count "
+ "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'DEPOSIT' "
+ "AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Out: settlements to vault
final String sqlOut1 = "SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count "
+ "FROM m_cash_settlement_detail csd "
+ "JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id "
+ "WHERE cs.from_cashier_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false) "
+ "AND cs.settlement_date <= CAST(? AS DATE) "
+ "GROUP BY csd.denomination_id, csd.denomination_value, dm.kind";
// Out: inter-cashier out
final String sqlOut2 = "SELECT icd.denomination_id, icd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(icd.count) AS total_count "
+ "FROM m_cash_inter_cashier_detail icd "
+ "JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = icd.denomination_id "
+ "WHERE ic.from_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false) "
+ "AND ic.transfer_date <= CAST(? AS DATE) "
+ "GROUP BY icd.denomination_id, icd.denomination_value, dm.kind";
// Out: customer withdrawals paid by this cashier
final String sqlOut3 = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(std.count) AS total_count "
+ "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'WITHDRAWAL' "
+ "AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// In: cash exchange — customer gave denomination TO cashier (RECEIVED direction)
final String sqlIn4 = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ced.count) AS total_count "
+ "FROM m_cash_exchange_detail ced "
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
+ "WHERE ce.cashier_id = ? AND ced.direction = 'RECEIVED' "
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) "
+ "AND ce.exchange_date <= CAST(? AS DATE) "
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind";
// Out: cash exchange — cashier gave denomination TO customer (GIVEN direction)
final String sqlOut4 = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ced.count) AS total_count "
+ "FROM m_cash_exchange_detail ced "
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
+ "WHERE ce.cashier_id = ? AND ced.direction = 'GIVEN' "
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) "
+ "AND ce.exchange_date <= CAST(? AS DATE) "
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind";
// Balance map: denomination_id → long[3] {denomValue, kindFlag (0=note/1=coin), netCount}
Map<Long, long[]> balance = new LinkedHashMap<>();
addToBalance(balance, jdbcTemplate.query(sqlIn1, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn2, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn3, DENOM_ROW, dateFilter, cashierId), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn4, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlOut1, DENOM_ROW, cashierId, dateFilter), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut2, DENOM_ROW, cashierId, dateFilter), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut3, DENOM_ROW, dateFilter, cashierId), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut4, DENOM_ROW, cashierId, dateFilter), -1);
List<Map<String, Object>> rows = new ArrayList<>();
balance.entrySet().stream()
.sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0]))
.forEach(e -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", e.getKey());
row.put("denominationValue", (int) e.getValue()[0]);
row.put("kind", e.getValue()[1] == 1L ? "coin" : "note");
row.put("expectedCount", e.getValue()[2]);
row.put("expectedAmount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2]));
rows.add(row);
});
BigDecimal grandTotal = rows.stream()
.map(r -> (BigDecimal) r.get("expectedAmount"))
.reduce(BigDecimal.ZERO, BigDecimal::add);
JsonObject result = new JsonObject();
result.addProperty("cashierId", cashierId);
result.addProperty("asOfDate", dateFilter);
result.addProperty("expectedTotal", grandTotal);
result.add("denominations", gson.toJsonTree(rows));
return gson.toJson(result);
}
/**
* GET /cashreconciliation/expected/me
* Same as /expected but auto-detects cashierId from the authenticated user.
*/
@GET
@Path("expected/me")
public String getExpectedForMe(@QueryParam("asOfDate") String asOfDate) {
context.authenticatedUser().validateHasPermissionTo("READ_CASHRECONCILIATION");
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString();
Long appUserId;
try {
appUserId = context.authenticatedUser().getId();
} catch (Exception e) {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.not.authenticated", "Not authenticated.");
}
Long cashierId;
try {
cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
Long.class, appUserId);
} catch (Exception e) {
Map<String, Object> empty = new LinkedHashMap<>();
empty.put("cashierId", null);
empty.put("asOfDate", dateFilter);
empty.put("expectedTotal", java.math.BigDecimal.ZERO);
empty.put("denominations", new ArrayList<>());
return gson.toJson(empty);
}
// Delegate to the existing /expected logic
return getExpected(cashierId, dateFilter);
}
/**
* GET /cashreconciliation/check?cashierId=X&date=yyyy-MM-dd
* Returns whether reconciliation has been done today for a cashier.
*/
@GET
@Path("check")
public String check(@QueryParam("cashierId") Long cashierId, @QueryParam("date") String date) {
context.authenticatedUser().validateHasPermissionTo("READ_CASHRECONCILIATION");
if (cashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId query param is required.");
}
final String reconDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString();
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_reconciliation WHERE cashier_id = ? AND recon_date = CAST(? AS DATE)",
Integer.class, cashierId, reconDate);
Map<String, Object> result = new LinkedHashMap<>();
result.put("cashierId", cashierId);
result.put("date", reconDate);
result.put("reconciled", count != null && count > 0);
return gson.toJson(result);
}
@POST
@Transactional
public String create(String body) {
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHRECONCILIATION");
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final Long cashierId = json.has("cashierId") ? json.get("cashierId").getAsLong() : null;
if (cashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashreconciliation.cashier.required",
"cashierId is required.");
}
final LocalDate reconDate = json.has("reconDate") && !json.get("reconDate").isJsonNull()
? LocalDate.parse(json.get("reconDate").getAsString()) : LocalDate.now();
final String cashierName = json.has("cashierName") && !json.get("cashierName").isJsonNull()
? json.get("cashierName").getAsString() : null;
final Long tellerId = json.has("tellerId") && !json.get("tellerId").isJsonNull()
? json.get("tellerId").getAsLong() : null;
final String note = json.has("note") && !json.get("note").isJsonNull()
? json.get("note").getAsString() : null;
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
? json.getAsJsonArray("denominations") : new JsonArray();
BigDecimal expectedTotal = BigDecimal.ZERO;
BigDecimal physicalTotal = BigDecimal.ZERO;
List<CashReconciliationDetail> details = new ArrayList<>();
for (JsonElement el : denominations) {
JsonObject item = el.getAsJsonObject();
int denomValue = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
int expectedCount = item.has("expectedCount") ? item.get("expectedCount").getAsInt() : 0;
int physicalCount = item.has("physicalCount") ? item.get("physicalCount").getAsInt() : 0;
int varianceCount = physicalCount - expectedCount;
BigDecimal varianceAmount = BigDecimal.valueOf((long) denomValue * varianceCount);
expectedTotal = expectedTotal.add(BigDecimal.valueOf((long) denomValue * expectedCount));
physicalTotal = physicalTotal.add(BigDecimal.valueOf((long) denomValue * physicalCount));
Long denomId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
? item.get("denominationId").getAsLong() : null;
CashReconciliationDetail d = new CashReconciliationDetail();
d.setDenominationId(denomId);
d.setDenominationValue(denomValue);
d.setExpectedCount(expectedCount);
d.setPhysicalCount(physicalCount);
d.setVarianceCount(varianceCount);
d.setVarianceAmount(varianceAmount);
details.add(d);
}
BigDecimal variance = physicalTotal.subtract(expectedTotal);
String status = variance.compareTo(BigDecimal.ZERO) == 0 ? "OK"
: variance.compareTo(BigDecimal.ZERO) > 0 ? "EXCESS" : "SHORT";
CashReconciliation header = new CashReconciliation();
header.setReconDate(reconDate);
header.setCashierId(cashierId);
header.setCashierName(cashierName);
header.setTellerId(tellerId);
header.setExpectedAmount(expectedTotal);
header.setPhysicalAmount(physicalTotal);
header.setVariance(variance);
header.setStatus(status);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now());
try {
AppUser user = this.context.authenticatedUser();
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
} catch (Exception ignored) {}
CashReconciliation saved = repository.saveAndFlush(header);
for (CashReconciliationDetail d : details) {
d.setReconciliationId(saved.getId());
detailRepository.save(d);
}
JsonObject result = new JsonObject();
result.addProperty("resourceId", saved.getId());
result.addProperty("status", status);
result.addProperty("variance", variance);
return gson.toJson(result);
}
@GET
public String getAll() {
return gson.toJson(repository.findAllByOrderByCreatedAtDesc().stream()
.map(this::toMap).collect(Collectors.toList()));
}
@GET
@Path("{id}")
public String getOne(@PathParam("id") Long id) {
CashReconciliation header = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Cash reconciliation not found: " + id));
Map<String, Object> map = toMap(header);
map.put("details", detailRepository.findByReconciliationId(id).stream()
.map(this::detailToMap).collect(Collectors.toList()));
return gson.toJson(map);
}
// Row mapper: Object[]{Long denomId, Integer denomValue, String kind, Long count}
private static final org.springframework.jdbc.core.RowMapper<Object[]> DENOM_ROW =
(rs, r) -> new Object[] {
rs.getLong("denomination_id"),
rs.getInt("denomination_value"),
rs.getString("kind"),
rs.getLong("total_count")
};
private void addToBalance(Map<Long, long[]> balance, List<Object[]> rows, int sign) {
for (Object[] row : rows) {
Long denomId = (Long) row[0];
int denomValue = (Integer) row[1];
long kindFlag = "coin".equalsIgnoreCase((String) row[2]) ? 1L : 0L;
long count = (Long) row[3];
long[] entry = balance.computeIfAbsent(denomId, k -> new long[] { denomValue, kindFlag, 0L });
entry[2] += count * sign;
}
}
private Map<String, Object> toMap(CashReconciliation e) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", e.getId());
m.put("reconDate", e.getReconDate() != null ? e.getReconDate().toString() : null);
m.put("cashierId", e.getCashierId());
m.put("cashierName", e.getCashierName());
m.put("tellerId", e.getTellerId());
m.put("expectedAmount", e.getExpectedAmount());
m.put("physicalAmount", e.getPhysicalAmount());
m.put("variance", e.getVariance());
m.put("status", e.getStatus());
m.put("note", e.getNote());
m.put("performedByUsername", e.getPerformedByUsername());
m.put("createdAt", e.getCreatedAt() != null ? e.getCreatedAt().toString() : null);
return m;
}
private Map<String, Object> detailToMap(CashReconciliationDetail d) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", d.getId());
m.put("denominationId", d.getDenominationId());
m.put("denominationValue", d.getDenominationValue());
m.put("expectedCount", d.getExpectedCount());
m.put("physicalCount", d.getPhysicalCount());
m.put("varianceCount", d.getVarianceCount());
m.put("varianceAmount", d.getVarianceAmount());
return m;
}
}
@@ -0,0 +1,62 @@
package org.apache.fineract.portfolio.cashreconciliation.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_reconciliation")
@Getter
@Setter
@NoArgsConstructor
public class CashReconciliation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "recon_date", nullable = false)
private LocalDate reconDate;
@Column(name = "cashier_id", nullable = false)
private Long cashierId;
@Column(name = "cashier_name", length = 150)
private String cashierName;
@Column(name = "teller_id")
private Long tellerId;
@Column(name = "expected_amount")
private BigDecimal expectedAmount = BigDecimal.ZERO;
@Column(name = "physical_amount")
private BigDecimal physicalAmount = BigDecimal.ZERO;
@Column(name = "variance")
private BigDecimal variance = BigDecimal.ZERO;
@Column(name = "status", length = 10)
private String status = "OK";
@Column(name = "note", length = 500)
private String note;
@Column(name = "performed_by")
private Long performedBy;
@Column(name = "performed_by_username", length = 100)
private String performedByUsername;
@Column(name = "created_at")
private LocalDateTime createdAt;
}
@@ -0,0 +1,45 @@
package org.apache.fineract.portfolio.cashreconciliation.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_reconciliation_detail")
@Getter
@Setter
@NoArgsConstructor
public class CashReconciliationDetail {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "reconciliation_id", nullable = false)
private Long reconciliationId;
@Column(name = "denomination_id")
private Long denominationId;
@Column(name = "denomination_value", nullable = false)
private Integer denominationValue;
@Column(name = "expected_count")
private Integer expectedCount = 0;
@Column(name = "physical_count")
private Integer physicalCount = 0;
@Column(name = "variance_count")
private Integer varianceCount = 0;
@Column(name = "variance_amount")
private BigDecimal varianceAmount = BigDecimal.ZERO;
}
@@ -0,0 +1,10 @@
package org.apache.fineract.portfolio.cashreconciliation.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashReconciliationDetailRepository extends JpaRepository<CashReconciliationDetail, Long> {
List<CashReconciliationDetail> findByReconciliationId(Long reconciliationId);
}
@@ -0,0 +1,11 @@
package org.apache.fineract.portfolio.cashreconciliation.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashReconciliationRepository extends JpaRepository<CashReconciliation, Long> {
List<CashReconciliation> findAllByOrderByCreatedAtDesc();
List<CashReconciliation> findByCashierIdOrderByReconDateDesc(Long cashierId);
}
@@ -0,0 +1,437 @@
package org.apache.fineract.portfolio.cashreport.api;
import com.google.gson.Gson;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
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 lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@Path("/v1/cashreports")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class CashReportApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
@GET
@Path("closing")
public String closing(@QueryParam("date") String date) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString();
// Single query — conditional aggregation for deposit, withdrawal, net per denomination
final String sql = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.count ELSE 0 END) AS deposit_count, "
+ "SUM(CASE WHEN std.txn_type = 'DEPOSIT' THEN std.total_amount ELSE 0 END) AS deposit_amount, "
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.count ELSE 0 END) AS withdrawal_count, "
+ "SUM(CASE WHEN std.txn_type = 'WITHDRAWAL' THEN std.total_amount ELSE 0 END) AS withdrawal_amount "
+ "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_date = CAST(? AS DATE) "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind "
+ "ORDER BY std.denomination_value DESC, dm.kind";
final List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
long depositCount = rs.getLong("deposit_count");
long withdrawalCount = rs.getLong("withdrawal_count");
BigDecimal depositAmount = rs.getBigDecimal("deposit_amount");
BigDecimal withdrawalAmount = rs.getBigDecimal("withdrawal_amount");
Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", rs.getLong("denomination_id"));
row.put("denominationValue", rs.getInt("denomination_value"));
row.put("kind", rs.getString("kind"));
row.put("depositCount", depositCount);
row.put("depositAmount", depositAmount);
row.put("withdrawalCount", withdrawalCount);
row.put("withdrawalAmount", withdrawalAmount);
row.put("netCount", depositCount - withdrawalCount);
row.put("netAmount", depositAmount.subtract(withdrawalAmount));
return row;
}, txnDate);
BigDecimal depositTotal = BigDecimal.ZERO;
BigDecimal withdrawalTotal = BigDecimal.ZERO;
for (Map<String, Object> row : rows) {
depositTotal = depositTotal.add((BigDecimal) row.get("depositAmount"));
withdrawalTotal = withdrawalTotal.add((BigDecimal) row.get("withdrawalAmount"));
}
// Cash exchange rows — separate section (denomination swap, net monetary = 0)
final String exchangeSql = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.count ELSE 0 END) AS received_count, "
+ "SUM(CASE WHEN ced.direction = 'RECEIVED' THEN ced.total_amount ELSE 0 END) AS received_amount, "
+ "SUM(CASE WHEN ced.direction = 'GIVEN' THEN ced.count ELSE 0 END) AS given_count, "
+ "SUM(CASE WHEN ced.direction = 'GIVEN' THEN ced.total_amount ELSE 0 END) AS given_amount "
+ "FROM m_cash_exchange_detail ced "
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
+ "WHERE ce.exchange_date = CAST(? AS DATE) "
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) "
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind "
+ "ORDER BY ced.denomination_value DESC, dm.kind";
final List<Map<String, Object>> exchangeRows = jdbcTemplate.query(exchangeSql, (rs, rowNum) -> {
long receivedCount = rs.getLong("received_count");
long givenCount = rs.getLong("given_count");
BigDecimal receivedAmount = rs.getBigDecimal("received_amount");
BigDecimal givenAmount = rs.getBigDecimal("given_amount");
Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", rs.getLong("denomination_id"));
row.put("denominationValue", rs.getInt("denomination_value"));
row.put("kind", rs.getString("kind"));
row.put("receivedCount", receivedCount);
row.put("receivedAmount", receivedAmount);
row.put("givenCount", givenCount);
row.put("givenAmount", givenAmount);
row.put("netCount", receivedCount - givenCount);
row.put("netAmount", receivedAmount.subtract(givenAmount));
return row;
}, txnDate);
Map<String, Object> result = new LinkedHashMap<>();
result.put("date", txnDate);
result.put("rows", rows);
result.put("depositTotal", depositTotal);
result.put("withdrawalTotal", withdrawalTotal);
result.put("netTotal", depositTotal.subtract(withdrawalTotal));
result.put("exchangeRows", exchangeRows);
return gson.toJson(result);
}
@GET
@Path("excess")
public String excess(@QueryParam("threshold") BigDecimal threshold, @QueryParam("fromDate") String fromDate,
@QueryParam("toDate") String toDate) {
final BigDecimal effectiveThreshold = threshold != null ? threshold : BigDecimal.valueOf(50000);
final boolean hasDateRange = fromDate != null && !fromDate.isBlank() && toDate != null && !toDate.isBlank();
// Savings / Current / FD / RD transactions
final StringBuilder sql = new StringBuilder();
sql.append("SELECT txn_id, MAX(account_no) account_no, MAX(client_id) client_id, MAX(cif_no) cif_no, ");
sql.append("MAX(txn_date) txn_date, SUM(total_amount) total_amount FROM m_savings_txn_denomination ");
final List<Object> args = new ArrayList<>();
if (hasDateRange) {
sql.append("WHERE txn_date BETWEEN CAST(? AS DATE) AND CAST(? AS DATE) ");
args.add(fromDate);
args.add(toDate);
}
sql.append("GROUP BY txn_id HAVING SUM(total_amount) >= ? ORDER BY total_amount DESC");
args.add(effectiveThreshold);
final List<Map<String, Object>> savingsRows = jdbcTemplate.query(sql.toString(), (rs, rowNum) -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("type", "SAVINGS_TXN");
row.put("txnId", rs.getLong("txn_id"));
row.put("accountNo", rs.getString("account_no"));
row.put("clientId", rs.getObject("client_id"));
row.put("cifNo", rs.getString("cif_no"));
final java.sql.Date d = rs.getDate("txn_date");
row.put("txnDate", d != null ? d.toString() : null);
row.put("totalAmount", rs.getBigDecimal("total_amount"));
row.put("idProof", null);
return row;
}, args.toArray());
// Cash exchange transactions
final StringBuilder exSql = new StringBuilder();
exSql.append("SELECT id, account_no, client_id, exchange_date, exchange_amount, customer_id_proof, performed_by_username ");
exSql.append("FROM m_cash_exchange ");
exSql.append("WHERE (is_reversed IS NULL OR is_reversed = false) AND exchange_amount >= ? ");
final List<Object> exArgs = new ArrayList<>();
exArgs.add(effectiveThreshold);
if (hasDateRange) {
exSql.append("AND exchange_date BETWEEN CAST(? AS DATE) AND CAST(? AS DATE) ");
exArgs.add(fromDate);
exArgs.add(toDate);
}
exSql.append("ORDER BY exchange_amount DESC");
final List<Map<String, Object>> exchangeRows = jdbcTemplate.query(exSql.toString(), (rs, rowNum) -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("type", "EXCHANGE");
row.put("txnId", rs.getLong("id"));
row.put("accountNo", rs.getString("account_no"));
row.put("clientId", rs.getObject("client_id"));
row.put("cifNo", null);
final java.sql.Date d = rs.getDate("exchange_date");
row.put("txnDate", d != null ? d.toString() : null);
row.put("totalAmount", rs.getBigDecimal("exchange_amount"));
row.put("idProof", rs.getString("customer_id_proof"));
return row;
}, exArgs.toArray());
// Merge and sort by amount DESC
final List<Map<String, Object>> combined = new ArrayList<>();
combined.addAll(savingsRows);
combined.addAll(exchangeRows);
combined.sort((a, b) -> ((BigDecimal) b.get("totalAmount")).compareTo((BigDecimal) a.get("totalAmount")));
return gson.toJson(combined);
}
/**
* GET /cashreports/cashier-position?cashierId=X&asOfDate=yyyy-MM-dd
* Returns denomination-wise net position per cashier grouped by denomination_id+kind.
*/
@GET
@Path("cashier-position")
public String cashierPosition(@QueryParam("cashierId") Long cashierId,
@QueryParam("asOfDate") String asOfDate) {
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString();
if (cashierId != null) {
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
}
final String idSql = "SELECT DISTINCT cashier_id FROM ("
+ " SELECT to_cashier_id AS cashier_id FROM m_cash_transfer WHERE to_cashier_id IS NOT NULL "
+ " UNION SELECT from_cashier_id FROM m_cash_settlement WHERE from_cashier_id IS NOT NULL "
+ " UNION SELECT from_cashier_id FROM m_cash_inter_cashier WHERE from_cashier_id IS NOT NULL "
+ " UNION SELECT to_cashier_id FROM m_cash_inter_cashier WHERE to_cashier_id IS NOT NULL "
+ " UNION SELECT cashier_id FROM m_cash_exchange WHERE cashier_id IS NOT NULL "
+ ") ids";
List<Long> cashierIds = jdbcTemplate.queryForList(idSql, Long.class);
List<Map<String, Object>> allPositions = new ArrayList<>();
for (Long cid : cashierIds) {
allPositions.add(buildPositionForCashier(cid, dateFilter));
}
return gson.toJson(allPositions);
}
@GET
@Path("cashier-position/me")
public String cashierPositionMe(@QueryParam("asOfDate") String asOfDate) {
final String dateFilter = (asOfDate != null && !asOfDate.isBlank()) ? asOfDate : LocalDate.now().toString();
Long appUserId;
try {
appUserId = context.authenticatedUser().getId();
} catch (Exception e) {
Map<String, Object> err = new LinkedHashMap<>();
err.put("error", "Not authenticated");
return gson.toJson(err);
}
Long cashierId;
try {
cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
Long.class, appUserId);
} catch (Exception e) {
Map<String, Object> empty = new LinkedHashMap<>();
empty.put("cashierId", null);
empty.put("asOfDate", dateFilter);
empty.put("totalAmount", BigDecimal.ZERO);
empty.put("denominations", new ArrayList<>());
return gson.toJson(empty);
}
return gson.toJson(buildPositionForCashier(cashierId, dateFilter));
}
/**
* GET /cashreports/cashier-morning?cashierId=X&date=YYYY-MM-DD
* Returns denomination-wise cash received by the cashier from vault on the given date
* (today's morning opening allocation from m_cash_transfer only, non-reversed).
*/
@GET
@Path("cashier-morning")
public String cashierMorning(@QueryParam("cashierId") Long cashierId,
@QueryParam("date") String date) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString();
if (cashierId == null) {
Map<String, Object> empty = new LinkedHashMap<>();
empty.put("cashierId", null);
empty.put("date", txnDate);
empty.put("totalMorning", BigDecimal.ZERO);
empty.put("denominations", new ArrayList<>());
return gson.toJson(empty);
}
final String sql = "SELECT ctd.denomination_id, ctd.denomination_value, "
+ "COALESCE(dm.kind, 'note') AS kind, "
+ "SUM(ctd.count) AS total_count, "
+ "SUM(ctd.total_amount) AS total_amount "
+ "FROM m_cash_transfer_detail ctd "
+ "JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id "
+ "WHERE ct.to_cashier_id = ? "
+ "AND ct.transfer_date = CAST(? AS DATE) "
+ "AND (ct.is_reversed IS NULL OR ct.is_reversed = false) "
+ "GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind "
+ "ORDER BY ctd.denomination_value DESC";
List<Map<String, Object>> rows = jdbcTemplate.query(sql, (rs, rowNum) -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", rs.getLong("denomination_id"));
row.put("denominationValue", rs.getInt("denomination_value"));
row.put("kind", rs.getString("kind"));
row.put("morningCount", rs.getLong("total_count"));
row.put("morningAmount", rs.getBigDecimal("total_amount"));
return row;
}, cashierId, txnDate);
BigDecimal total = rows.stream()
.map(r -> (BigDecimal) r.get("morningAmount"))
.reduce(BigDecimal.ZERO, BigDecimal::add);
Map<String, Object> result = new LinkedHashMap<>();
result.put("cashierId", cashierId);
result.put("date", txnDate);
result.put("totalMorning", total);
result.put("denominations", rows);
return gson.toJson(result);
}
private Map<String, Object> buildPositionForCashier(Long cashierId, String dateFilter) {
// Vault → Cashier allocations — GROUP BY denomination_id+kind to keep coin and note separate
final String sqlIn1 = "SELECT ctd.denomination_id, ctd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ctd.count) AS total_count "
+ "FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ctd.denomination_id "
+ "WHERE ct.to_cashier_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false) "
+ "AND ct.transfer_date <= CAST(? AS DATE) "
+ "GROUP BY ctd.denomination_id, ctd.denomination_value, dm.kind";
// Inter-cashier IN
final String sqlIn2 = "SELECT icd.denomination_id, icd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(icd.count) AS total_count "
+ "FROM m_cash_inter_cashier_detail icd JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = icd.denomination_id "
+ "WHERE ic.to_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false) "
+ "AND ic.transfer_date <= CAST(? AS DATE) "
+ "GROUP BY icd.denomination_id, icd.denomination_value, dm.kind";
// Customer deposits
final String sqlIn3 = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(std.count) AS total_count "
+ "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'DEPOSIT' AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Settlements back to vault
final String sqlOut1 = "SELECT csd.denomination_id, csd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(csd.count) AS total_count "
+ "FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = csd.denomination_id "
+ "WHERE cs.from_cashier_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false) "
+ "AND cs.settlement_date <= CAST(? AS DATE) "
+ "GROUP BY csd.denomination_id, csd.denomination_value, dm.kind";
// Inter-cashier OUT
final String sqlOut2 = "SELECT icd.denomination_id, icd.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(icd.count) AS total_count "
+ "FROM m_cash_inter_cashier_detail icd JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = icd.denomination_id "
+ "WHERE ic.from_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false) "
+ "AND ic.transfer_date <= CAST(? AS DATE) "
+ "GROUP BY icd.denomination_id, icd.denomination_value, dm.kind";
// Customer withdrawals
final String sqlOut3 = "SELECT std.denomination_id, std.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(std.count) AS total_count "
+ "FROM m_savings_txn_denomination std "
+ "LEFT JOIN m_denomination_master dm ON dm.id = std.denomination_id "
+ "WHERE std.txn_type = 'WITHDRAWAL' AND std.txn_date <= CAST(? AS DATE) "
+ "AND std.officer_id IN ("
+ " SELECT au.id FROM m_appuser au "
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?) "
+ "GROUP BY std.denomination_id, std.denomination_value, dm.kind";
// Exchange IN: customer gave denomination to cashier (RECEIVED direction)
final String sqlIn4 = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ced.count) AS total_count "
+ "FROM m_cash_exchange_detail ced "
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
+ "WHERE ce.cashier_id = ? AND ced.direction = 'RECEIVED' "
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) "
+ "AND ce.exchange_date <= CAST(? AS DATE) "
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind";
// Exchange OUT: cashier gave denomination to customer (GIVEN direction)
final String sqlOut4 = "SELECT ced.denomination_id, ced.denomination_value, COALESCE(dm.kind, 'note') AS kind, SUM(ced.count) AS total_count "
+ "FROM m_cash_exchange_detail ced "
+ "JOIN m_cash_exchange ce ON ce.id = ced.exchange_id "
+ "LEFT JOIN m_denomination_master dm ON dm.id = ced.denomination_id "
+ "WHERE ce.cashier_id = ? AND ced.direction = 'GIVEN' "
+ "AND (ce.is_reversed IS NULL OR ce.is_reversed = false) "
+ "AND ce.exchange_date <= CAST(? AS DATE) "
+ "GROUP BY ced.denomination_id, ced.denomination_value, dm.kind";
// Balance map: denomination_id → long[3] {denomValue, kindFlag (0=note/1=coin), netCount}
Map<Long, long[]> balance = new LinkedHashMap<>();
addToBalance(balance, jdbcTemplate.query(sqlIn1, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn2, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn3, DENOM_ROW, dateFilter, cashierId), 1);
addToBalance(balance, jdbcTemplate.query(sqlIn4, DENOM_ROW, cashierId, dateFilter), 1);
addToBalance(balance, jdbcTemplate.query(sqlOut1, DENOM_ROW, cashierId, dateFilter), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut2, DENOM_ROW, cashierId, dateFilter), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut3, DENOM_ROW, dateFilter, cashierId), -1);
addToBalance(balance, jdbcTemplate.query(sqlOut4, DENOM_ROW, cashierId, dateFilter), -1);
List<Map<String, Object>> rows = new ArrayList<>();
balance.entrySet().stream()
.filter(e -> e.getValue()[2] != 0)
.sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0]))
.forEach(e -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("denominationId", e.getKey());
row.put("denominationValue", (int) e.getValue()[0]);
row.put("kind", e.getValue()[1] == 1L ? "coin" : "note");
row.put("count", e.getValue()[2]);
row.put("amount", BigDecimal.valueOf(e.getValue()[0] * e.getValue()[2]));
rows.add(row);
});
BigDecimal totalAmount = rows.stream()
.map(r -> (BigDecimal) r.get("amount"))
.reduce(BigDecimal.ZERO, BigDecimal::add);
String cashierName = null;
try {
cashierName = jdbcTemplate.queryForObject(
"SELECT ms.display_name FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id WHERE mc.id = ?",
String.class, cashierId);
} catch (Exception ignored) {}
Map<String, Object> result = new LinkedHashMap<>();
result.put("cashierId", cashierId);
result.put("cashierName", cashierName);
result.put("asOfDate", dateFilter);
result.put("totalAmount", totalAmount);
result.put("denominations", rows);
return result;
}
// Row mapper: returns Object[]{Long denomId, Integer denomValue, String kind, Long count}
private static final org.springframework.jdbc.core.RowMapper<Object[]> DENOM_ROW =
(rs, r) -> new Object[] {
rs.getLong("denomination_id"),
rs.getInt("denomination_value"),
rs.getString("kind"),
rs.getLong("total_count")
};
private void addToBalance(Map<Long, long[]> balance, List<Object[]> rows, int sign) {
for (Object[] row : rows) {
Long denomId = (Long) row[0];
int denomValue = (Integer) row[1];
long kindFlag = "coin".equalsIgnoreCase((String) row[2]) ? 1L : 0L;
long count = (Long) row[3];
long[] entry = balance.computeIfAbsent(denomId, k -> new long[] { denomValue, kindFlag, 0L });
entry[2] += count * sign;
}
}
}
@@ -0,0 +1,309 @@
package org.apache.fineract.portfolio.cashsettlement.api;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlement;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetail;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementDetailRepository;
import org.apache.fineract.portfolio.cashsettlement.domain.CashSettlementRepository;
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
@Path("/v1/cashsettlement")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class CashSettlementApiResource {
private final CashSettlementRepository repository;
private final CashSettlementDetailRepository detailRepository;
private final PlatformSecurityContext context;
private final GlDispatchService glDispatchService;
private final PlatformTransactionManager transactionManager;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
@POST
public String create(String body) {
context.authenticatedUser().validateHasPermissionTo("CREATE_CASHSETTLEMENT");
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final Long fromCashierId = json.has("fromCashierId") && !json.get("fromCashierId").isJsonNull()
? json.get("fromCashierId").getAsLong() : null;
final Long toTellerId = json.has("toTellerId") && !json.get("toTellerId").isJsonNull()
? json.get("toTellerId").getAsLong() : null;
if (fromCashierId == null || toTellerId == null) {
throw new PlatformDataIntegrityException("error.msg.cashsettlement.parties.required",
"fromCashierId and toTellerId are required.");
}
final LocalDate settlementDate = json.has("settlementDate") && !json.get("settlementDate").isJsonNull()
? LocalDate.parse(json.get("settlementDate").getAsString()) : LocalDate.now();
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull()
? json.get("fromParty").getAsString() : null;
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull()
? json.get("toParty").getAsString() : null;
final String note = json.has("note") && !json.get("note").isJsonNull()
? json.get("note").getAsString() : null;
final String currencyCode = json.has("currencyCode") && !json.get("currencyCode").isJsonNull()
? json.get("currencyCode").getAsString() : "INR";
// Require reconciliation before settlement
Integer reconCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_reconciliation WHERE cashier_id = ? AND recon_date = CAST(? AS DATE)",
Integer.class, fromCashierId, settlementDate.toString());
if (reconCount == null || reconCount == 0) {
throw new PlatformDataIntegrityException("error.msg.cashsettlement.reconciliation.required",
"Reconciliation must be completed before settlement for cashier " + fromCashierId + " on " + settlementDate + ". Please complete today's reconciliation first.");
}
// Prevent duplicate settlement for same cashier+date
Integer existingCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_settlement WHERE from_cashier_id = ? AND settlement_date = CAST(? AS DATE) AND (is_reversed IS NULL OR is_reversed = false)",
Integer.class, fromCashierId, settlementDate.toString());
if (existingCount != null && existingCount > 0) {
throw new PlatformDataIntegrityException("error.msg.cashsettlement.duplicate",
"A settlement for cashier " + fromCashierId + " on " + settlementDate + " already exists. Reverse the existing settlement first.");
}
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
? json.getAsJsonArray("denominations") : new JsonArray();
if (!hasPositiveCount(denominations)) {
throw new PlatformDataIntegrityException("error.msg.cashsettlement.empty",
"At least one denomination with a positive count is required.");
}
final BigDecimal total = sumTotal(denominations);
CashSettlement header = new CashSettlement();
header.setSettlementDate(settlementDate);
header.setFromCashierId(fromCashierId);
header.setToTellerId(toTellerId);
header.setFromParty(fromParty);
header.setToParty(toParty);
header.setSettlementAmount(total);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now());
header.setIsReversed(false);
try {
AppUser user = this.context.authenticatedUser();
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
} catch (Exception ignored) {}
// Commit header + details first, then GL dispatch separately to avoid
// EclipseLink flushing pending detail rows inside Fineract's GL transaction.
final Long savedId = new TransactionTemplate(transactionManager).execute(status -> {
CashSettlement saved = repository.saveAndFlush(header);
saveDetails(saved.getId(), denominations);
return saved.getId();
});
String glWarning = null;
try {
// Use cashier's own teller_id from m_cashiers for GL dispatch so the
// Fineract cashier-teller link is respected regardless of which vault teller
// the user submitted cash to.
Long glTellerId = toTellerId;
try {
Long resolved = jdbcTemplate.queryForObject(
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, fromCashierId);
if (resolved != null) glTellerId = resolved;
} catch (Exception ignored) {}
glDispatchService.settleCashFromCashier(glTellerId, fromCashierId, total,
currencyCode, settlementDate.format(DateTimeFormatter.ISO_LOCAL_DATE),
note != null ? note : "");
} catch (Exception e) {
glWarning = "Settlement saved but GL entry failed: " + e.getMessage();
}
JsonObject result = new JsonObject();
result.addProperty("resourceId", savedId);
if (glWarning != null) result.addProperty("glWarning", glWarning);
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
@Transactional
public String reverse(@PathParam("id") Long id, String body) {
context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHSETTLEMENT");
CashSettlement settlement = repository.findById(id)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashsettlement.not.found",
"Settlement not found: " + id));
if (Boolean.TRUE.equals(settlement.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashsettlement.already.reversed",
"Settlement " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {}
AppUser user = null;
try { user = this.context.authenticatedUser(); } catch (Exception ignored) {}
settlement.setIsReversed(true);
settlement.setReversedAt(LocalDateTime.now());
settlement.setReverseReason(reason);
if (user != null) settlement.setReversedBy(user.getId());
repository.save(settlement);
if (settlement.getToTellerId() != null && settlement.getFromCashierId() != null) {
try {
Long glTellerId = settlement.getToTellerId();
try {
Long resolved = jdbcTemplate.queryForObject(
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, settlement.getFromCashierId());
if (resolved != null) glTellerId = resolved;
} catch (Exception ignored) {}
glDispatchService.allocateCashToCashier(glTellerId,
settlement.getFromCashierId(), settlement.getSettlementAmount(), "INR",
LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE),
"Reversal of cash settlement #" + id + (reason != null ? ": " + reason : ""));
} catch (Exception ignored) {}
}
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
@Path("check")
public String check(@QueryParam("cashierId") Long cashierId, @QueryParam("date") String date) {
context.authenticatedUser().validateHasPermissionTo("READ_CASHSETTLEMENT");
if (cashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashsettlement.cashier.required",
"cashierId query param is required.");
}
final String settlementDate = (date != null && !date.isBlank()) ? date : LocalDate.now().toString();
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_settlement WHERE from_cashier_id = ? AND settlement_date = CAST(? AS DATE) AND (is_reversed IS NULL OR is_reversed = false)",
Integer.class, cashierId, settlementDate);
Map<String, Object> result = new LinkedHashMap<>();
result.put("cashierId", cashierId);
result.put("date", settlementDate);
result.put("alreadySettled", count != null && count > 0);
return gson.toJson(result);
}
@GET
public String getAll() {
List<CashSettlement> list = repository.findAllByOrderByCreatedAtDesc();
return gson.toJson(list.stream().map(this::toMap).collect(Collectors.toList()));
}
@GET
@Path("{id}")
public String getOne(@PathParam("id") Long id) {
CashSettlement header = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Cash settlement not found: " + id));
Map<String, Object> map = toMap(header);
List<Map<String, Object>> details = detailRepository.findBySettlementId(id).stream()
.map(this::detailToMap).collect(Collectors.toList());
map.put("details", details);
return gson.toJson(map);
}
private BigDecimal sumTotal(JsonArray items) {
BigDecimal total = BigDecimal.ZERO;
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int value = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
int count = item.has("count") ? item.get("count").getAsInt() : 0;
if (count > 0) total = total.add(BigDecimal.valueOf((long) value * count));
}
return total;
}
private boolean hasPositiveCount(JsonArray items) {
for (JsonElement el : items) {
if (el.getAsJsonObject().get("count").getAsInt() > 0) return true;
}
return false;
}
private void saveDetails(Long settlementId, JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") ? item.get("count").getAsInt() : 0;
if (count <= 0) continue;
int value = item.has("denominationValue") ? item.get("denominationValue").getAsInt() : 0;
Long denomId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
? item.get("denominationId").getAsLong() : null;
CashSettlementDetail d = new CashSettlementDetail();
d.setSettlementId(settlementId);
d.setDenominationId(denomId);
d.setDenominationValue(value);
d.setCount(count);
d.setTotalAmount(BigDecimal.valueOf((long) value * count));
detailRepository.save(d);
}
}
private Map<String, Object> toMap(CashSettlement e) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", e.getId());
m.put("settlementDate", e.getSettlementDate() != null ? e.getSettlementDate().toString() : null);
m.put("fromCashierId", e.getFromCashierId());
m.put("toTellerId", e.getToTellerId());
m.put("fromParty", e.getFromParty());
m.put("toParty", e.getToParty());
m.put("settlementAmount", e.getSettlementAmount());
m.put("note", e.getNote());
m.put("performedBy", e.getPerformedBy());
m.put("performedByUsername", e.getPerformedByUsername());
m.put("createdAt", e.getCreatedAt() != null ? e.getCreatedAt().toString() : null);
m.put("isReversed", e.getIsReversed());
m.put("reversedBy", e.getReversedBy());
m.put("reversedAt", e.getReversedAt() != null ? e.getReversedAt().toString() : null);
m.put("reverseReason", e.getReverseReason());
return m;
}
private Map<String, Object> detailToMap(CashSettlementDetail d) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", d.getId());
m.put("denominationId", d.getDenominationId());
m.put("denominationValue", d.getDenominationValue());
m.put("count", d.getCount());
m.put("totalAmount", d.getTotalAmount());
return m;
}
}
@@ -0,0 +1,68 @@
package org.apache.fineract.portfolio.cashsettlement.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_settlement")
@Getter
@Setter
@NoArgsConstructor
public class CashSettlement {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "settlement_date")
private LocalDate settlementDate;
@Column(name = "from_cashier_id")
private Long fromCashierId;
@Column(name = "to_teller_id")
private Long toTellerId;
@Column(name = "from_party", length = 100)
private String fromParty;
@Column(name = "to_party", length = 100)
private String toParty;
@Column(name = "settlement_amount", nullable = false)
private BigDecimal settlementAmount;
@Column(name = "note", length = 500)
private String note;
@Column(name = "performed_by")
private Long performedBy;
@Column(name = "performed_by_username", length = 100)
private String performedByUsername;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "is_reversed")
private Boolean isReversed = false;
@Column(name = "reversed_by")
private Long reversedBy;
@Column(name = "reversed_at")
private LocalDateTime reversedAt;
@Column(name = "reverse_reason", length = 500)
private String reverseReason;
}
@@ -0,0 +1,39 @@
package org.apache.fineract.portfolio.cashsettlement.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_settlement_detail")
@Getter
@Setter
@NoArgsConstructor
public class CashSettlementDetail {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "settlement_id", nullable = false)
private Long settlementId;
@Column(name = "denomination_id")
private Long denominationId;
@Column(name = "denomination_value", nullable = false)
private Integer denominationValue;
@Column(name = "count", nullable = false)
private Integer count;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
}
@@ -0,0 +1,10 @@
package org.apache.fineract.portfolio.cashsettlement.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashSettlementDetailRepository extends JpaRepository<CashSettlementDetail, Long> {
List<CashSettlementDetail> findBySettlementId(Long settlementId);
}
@@ -0,0 +1,11 @@
package org.apache.fineract.portfolio.cashsettlement.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashSettlementRepository extends JpaRepository<CashSettlement, Long> {
List<CashSettlement> findAllByOrderByCreatedAtDesc();
List<CashSettlement> findByFromCashierIdOrderByCreatedAtDesc(Long cashierId);
}
@@ -0,0 +1,298 @@
package org.apache.fineract.portfolio.cashtransfer.api;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.teller.service.GlDispatchService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransfer;
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransferDetail;
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransferDetailRepository;
import org.apache.fineract.portfolio.cashtransfer.domain.CashTransferRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
@Path("/v1/cashtransfer")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class CashTransferApiResource {
private final CashTransferRepository repository;
private final CashTransferDetailRepository detailRepository;
private final PlatformSecurityContext context;
private final GlDispatchService glDispatchService;
private final PlatformTransactionManager transactionManager;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
@POST
public String create(String body) {
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER");
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
? LocalDate.parse(json.get("transferDate").getAsString())
: null;
final String fromParty = json.has("fromParty") && !json.get("fromParty").isJsonNull() ? json.get("fromParty").getAsString()
: null;
final String toParty = json.has("toParty") && !json.get("toParty").isJsonNull() ? json.get("toParty").getAsString() : null;
final String note = json.has("note") && !json.get("note").isJsonNull() ? json.get("note").getAsString() : null;
final Long fromTellerId = json.has("fromTellerId") && !json.get("fromTellerId").isJsonNull()
? json.get("fromTellerId").getAsLong()
: null;
final Long toCashierId = json.has("toCashierId") && !json.get("toCashierId").isJsonNull()
? json.get("toCashierId").getAsLong()
: null;
if (fromTellerId == null || toCashierId == null) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.parties.required",
"fromTellerId and toCashierId are required.");
}
final String currencyCode = json.has("currencyCode") && !json.get("currencyCode").isJsonNull()
? json.get("currencyCode").getAsString()
: "INR";
final JsonArray denominations = json.has("denominations") && json.get("denominations").isJsonArray()
? json.getAsJsonArray("denominations")
: new JsonArray();
if (!hasPositiveCount(denominations)) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.empty",
"At least one denomination with a positive count is required.");
}
final BigDecimal total = sumTotal(denominations);
if (total.compareTo(BigDecimal.ZERO) <= 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.empty",
"At least one denomination with a positive count is required.");
}
CashTransfer header = new CashTransfer();
header.setTransferDate(transferDate);
header.setFromParty(fromParty);
header.setToParty(toParty);
header.setFromTellerId(fromTellerId);
header.setToCashierId(toCashierId);
header.setTransferAmount(total);
header.setNote(note);
header.setCreatedAt(LocalDateTime.now());
try {
AppUser user = this.context.authenticatedUser();
if (user != null) {
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
}
} catch (Exception e) {
// resolving the current user must never block recording the transfer
}
// Save header + details in one committed transaction, then GL dispatch separately.
// This prevents EclipseLink from flushing pending detail rows inside Fineract's
// ALLOCATECASHTOCASHIER transaction, which caused null transfer_id constraint errors.
final Long savedId = new TransactionTemplate(transactionManager).execute(status -> {
CashTransfer saved = repository.saveAndFlush(header);
saveDetails(saved.getId(), denominations);
return saved.getId();
});
String glWarning = null;
try {
// Use the cashier's own teller_id from m_cashiers for GL dispatch.
// Fineract's allocateCashToCashier requires cashier and teller to be linked —
// the user-selected fromTellerId is the business vault but may differ from the
// cashier's registered teller_id. Always resolve from DB to avoid GL failures.
Long glTellerId = fromTellerId;
try {
Long resolved = jdbcTemplate.queryForObject(
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, toCashierId);
if (resolved != null) glTellerId = resolved;
} catch (Exception ignored) {}
final LocalDate txnDate = transferDate != null ? transferDate : LocalDate.now();
glDispatchService.allocateCashToCashier(glTellerId, toCashierId, total,
currencyCode, txnDate.format(DateTimeFormatter.ISO_LOCAL_DATE),
note != null ? note : "");
} catch (Exception e) {
glWarning = "Transfer saved but GL entry failed: " + e.getMessage();
}
JsonObject result = new JsonObject();
result.addProperty("resourceId", savedId);
if (glWarning != null) result.addProperty("glWarning", glWarning);
return gson.toJson(result);
}
@POST
@Path("{id}/reverse")
@Transactional
public String reverse(@PathParam("id") Long id, String body) {
this.context.authenticatedUser().validateHasPermissionTo("REVERSE_CASHTRANSFER");
CashTransfer transfer = repository.findById(id)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.cashtransfer.not.found",
"Cash transfer not found: " + id));
if (Boolean.TRUE.equals(transfer.getIsReversed())) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.already.reversed",
"Cash transfer " + id + " is already reversed.");
}
String reason = null;
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
reason = json.has("reason") ? json.get("reason").getAsString() : null;
} catch (Exception ignored) {}
try {
AppUser user = this.context.authenticatedUser();
transfer.setReversedBy(user.getId());
} catch (Exception ignored) {}
transfer.setIsReversed(true);
transfer.setReversedAt(java.time.LocalDateTime.now());
transfer.setReverseReason(reason);
repository.save(transfer);
if (transfer.getFromTellerId() != null && transfer.getToCashierId() != null) {
try {
Long glTellerId = transfer.getFromTellerId();
try {
Long resolved = jdbcTemplate.queryForObject(
"SELECT teller_id FROM m_cashiers WHERE id = ?", Long.class, transfer.getToCashierId());
if (resolved != null) glTellerId = resolved;
} catch (Exception ignored) {}
glDispatchService.settleCashFromCashier(glTellerId,
transfer.getToCashierId(), transfer.getTransferAmount(), "INR",
java.time.LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE),
"Reversal of cash transfer #" + id + (reason != null ? ": " + reason : ""));
} catch (Exception ignored) {}
}
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("reversed", true);
return gson.toJson(result);
}
@GET
public String getAll() {
List<CashTransfer> list = repository.findAllByOrderByCreatedAtDesc();
return gson.toJson(list.stream().map(this::toMap).collect(Collectors.toList()));
}
@GET
@Path("{id}")
public String getOne(@PathParam("id") Long id) {
CashTransfer header = repository.findById(id)
.orElseThrow(() -> new RuntimeException("Cash transfer not found: " + id));
Map<String, Object> map = toMap(header);
List<Map<String, Object>> details = detailRepository.findByTransferId(id).stream().map(this::detailToMap)
.collect(Collectors.toList());
map.put("details", details);
return gson.toJson(map);
}
private BigDecimal sumTotal(JsonArray items) {
BigDecimal total = BigDecimal.ZERO;
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt()
: 0;
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count > 0) {
total = total.add(BigDecimal.valueOf((long) value * count));
}
}
return total;
}
private boolean hasPositiveCount(JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count > 0) {
return true;
}
}
return false;
}
private void saveDetails(Long transferId, JsonArray items) {
for (JsonElement el : items) {
JsonObject item = el.getAsJsonObject();
int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) {
continue;
}
int value = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt()
: 0;
Long denominationId = item.has("denominationId") && !item.get("denominationId").isJsonNull()
? item.get("denominationId").getAsLong()
: null;
CashTransferDetail detail = new CashTransferDetail();
detail.setTransferId(transferId);
detail.setDenominationId(denominationId);
detail.setDenominationValue(value);
detail.setCount(count);
detail.setTotalAmount(BigDecimal.valueOf((long) value * count));
detailRepository.save(detail);
}
}
private Map<String, Object> toMap(CashTransfer e) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", e.getId());
map.put("transferDate", e.getTransferDate() != null ? e.getTransferDate().toString() : null);
map.put("fromParty", e.getFromParty());
map.put("toParty", e.getToParty());
map.put("fromTellerId", e.getFromTellerId());
map.put("toCashierId", e.getToCashierId());
map.put("transferAmount", e.getTransferAmount());
map.put("note", e.getNote());
map.put("performedBy", e.getPerformedBy());
map.put("performedByUsername", e.getPerformedByUsername());
map.put("createdAt", e.getCreatedAt() != null ? e.getCreatedAt().toString() : null);
map.put("isReversed", Boolean.TRUE.equals(e.getIsReversed()));
map.put("reversedBy", e.getReversedBy());
map.put("reversedAt", e.getReversedAt() != null ? e.getReversedAt().toString() : null);
map.put("reverseReason", e.getReverseReason());
return map;
}
private Map<String, Object> detailToMap(CashTransferDetail d) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", d.getId());
map.put("denominationId", d.getDenominationId());
map.put("denominationValue", d.getDenominationValue());
map.put("count", d.getCount());
map.put("totalAmount", d.getTotalAmount());
return map;
}
}
@@ -0,0 +1,72 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.
*/
package org.apache.fineract.portfolio.cashtransfer.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_transfer")
@Getter
@Setter
@NoArgsConstructor
public class CashTransfer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "transfer_date")
private LocalDate transferDate;
@Column(name = "from_party", length = 100)
private String fromParty;
@Column(name = "to_party", length = 100)
private String toParty;
@Column(name = "transfer_amount", nullable = false)
private BigDecimal transferAmount;
@Column(name = "note", length = 500)
private String note;
@Column(name = "performed_by")
private Long performedBy;
@Column(name = "performed_by_username", length = 100)
private String performedByUsername;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "from_teller_id")
private Long fromTellerId;
@Column(name = "to_cashier_id")
private Long toCashierId;
@Column(name = "is_reversed")
private Boolean isReversed = false;
@Column(name = "reversed_by")
private Long reversedBy;
@Column(name = "reversed_at")
private java.time.LocalDateTime reversedAt;
@Column(name = "reverse_reason", length = 500)
private String reverseReason;
}
@@ -0,0 +1,43 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.
*/
package org.apache.fineract.portfolio.cashtransfer.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_cash_transfer_detail")
@Getter
@Setter
@NoArgsConstructor
public class CashTransferDetail {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "transfer_id", nullable = false)
private Long transferId;
@Column(name = "denomination_id")
private Long denominationId;
@Column(name = "denomination_value", nullable = false)
private Integer denominationValue;
@Column(name = "count", nullable = false)
private Integer count;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount;
}
@@ -0,0 +1,10 @@
package org.apache.fineract.portfolio.cashtransfer.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashTransferDetailRepository extends JpaRepository<CashTransferDetail, Long> {
List<CashTransferDetail> findByTransferId(Long transferId);
}
@@ -0,0 +1,10 @@
package org.apache.fineract.portfolio.cashtransfer.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CashTransferRepository extends JpaRepository<CashTransfer, Long> {
List<CashTransfer> findAllByOrderByCreatedAtDesc();
}
@@ -0,0 +1,232 @@
package org.apache.fineract.portfolio.denominationmaster.api;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.denominationmaster.domain.DenominationMaster;
import org.apache.fineract.portfolio.denominationmaster.domain.DenominationMasterAudit;
import org.apache.fineract.portfolio.denominationmaster.domain.DenominationMasterAuditRepository;
import org.apache.fineract.portfolio.denominationmaster.domain.DenominationMasterRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
@Path("/v1/denominationmaster")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class DenominationMasterApiResource {
private final DenominationMasterRepository repository;
private final DenominationMasterAuditRepository auditRepository;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
@GET
public String getAll() {
List<DenominationMaster> list = repository.findByDeletedFalseOrderByKindDescDenominationDesc();
return gson.toJson(list.stream().map(this::toMap).collect(Collectors.toList()));
}
@GET
@Path("active")
public String getActive() {
List<DenominationMaster> list = repository.findByIsActiveTrueAndDeletedFalseOrderByKindDescDenominationDesc();
return gson.toJson(list.stream().map(this::toMap).collect(Collectors.toList()));
}
@GET
@Path("deleted")
public String getDeleted() {
List<DenominationMaster> list = repository.findByDeletedTrueOrderByKindDescDenominationDesc();
return gson.toJson(list.stream().map(this::toMap).collect(Collectors.toList()));
}
@GET
@Path("audit")
public String getAllAudit() {
List<DenominationMasterAudit> list = auditRepository.findAllByOrderByPerformedOnDesc();
return gson.toJson(list.stream().map(this::auditToMap).collect(Collectors.toList()));
}
@GET
@Path("{id}/audit")
public String getAuditForDenomination(@PathParam("id") Long id) {
List<DenominationMasterAudit> list = auditRepository.findByDenominationIdOrderByPerformedOnDesc(id);
return gson.toJson(list.stream().map(this::auditToMap).collect(Collectors.toList()));
}
@POST
public String create(String body) {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final int denomination = json.get("denomination").getAsInt();
final String kind = json.get("kind").getAsString();
if (repository.existsByDenominationAndKindAndDeletedFalse(denomination, kind)) {
throw new PlatformDataIntegrityException("error.msg.denomination.duplicate",
"A " + kind + " of denomination " + denomination + " already exists.", "denomination", denomination);
}
DenominationMaster dm = DenominationMaster.create(denomination, kind,
json.has("description") ? json.get("description").getAsString() : null,
json.has("isActive") ? json.get("isActive").getAsBoolean() : true,
json.has("introducedOn") ? LocalDate.parse(json.get("introducedOn").getAsString()) : null, null);
DenominationMaster saved = repository.save(dm);
writeAudit("CREATE", saved, null);
JsonObject result = new JsonObject();
result.addProperty("resourceId", saved.getId());
return gson.toJson(result);
}
@PUT
@Path("{id}")
public String update(@PathParam("id") Long id, @QueryParam("command") String command, String body) {
DenominationMaster dm = repository.findById(id).orElseThrow(() -> new RuntimeException("Denomination not found: " + id));
if ("activate".equals(command)) {
dm.setIsActive(true);
dm.setDiscontinuedOn(null);
repository.save(dm);
writeAudit("ACTIVATE", dm, null);
} else if ("deactivate".equals(command)) {
dm.setIsActive(false);
dm.setDiscontinuedOn(LocalDate.now());
repository.save(dm);
writeAudit("DEACTIVATE", dm, null);
} else if ("restore".equals(command)) {
if (repository.existsByDenominationAndKindAndDeletedFalseAndIdNot(dm.getDenomination(), dm.getKind(), id)) {
throw new PlatformDataIntegrityException("error.msg.denomination.duplicate.restore",
"Cannot restore: a " + dm.getKind() + " of denomination " + dm.getDenomination() + " already exists.",
"denomination", dm.getDenomination());
}
dm.setDeleted(false);
repository.save(dm);
writeAudit("RESTORE", dm, null);
} else {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final int effectiveDenomination = json.has("denomination") ? json.get("denomination").getAsInt() : dm.getDenomination();
final String effectiveKind = json.has("kind") ? json.get("kind").getAsString() : dm.getKind();
if (repository.existsByDenominationAndKindAndDeletedFalseAndIdNot(effectiveDenomination, effectiveKind, id)) {
throw new PlatformDataIntegrityException("error.msg.denomination.duplicate",
"A " + effectiveKind + " of denomination " + effectiveDenomination + " already exists.", "denomination",
effectiveDenomination);
}
List<String> changes = new ArrayList<>();
if (json.has("denomination")) {
int newVal = json.get("denomination").getAsInt();
if (!java.util.Objects.equals(dm.getDenomination(), newVal)) {
changes.add("denomination: " + dm.getDenomination() + " -> " + newVal);
}
dm.setDenomination(newVal);
}
if (json.has("kind")) {
String newVal = json.get("kind").getAsString();
if (!java.util.Objects.equals(dm.getKind(), newVal)) {
changes.add("kind: " + dm.getKind() + " -> " + newVal);
}
dm.setKind(newVal);
}
if (json.has("description")) {
String newVal = json.get("description").getAsString();
if (!java.util.Objects.equals(dm.getDescription(), newVal)) {
changes.add("description: " + dm.getDescription() + " -> " + newVal);
}
dm.setDescription(newVal);
}
if (json.has("isActive")) {
boolean newVal = json.get("isActive").getAsBoolean();
if (!java.util.Objects.equals(dm.getIsActive(), newVal)) {
changes.add("isActive: " + dm.getIsActive() + " -> " + newVal);
}
dm.setIsActive(newVal);
}
if (json.has("introducedOn")) {
LocalDate newVal = LocalDate.parse(json.get("introducedOn").getAsString());
if (!java.util.Objects.equals(dm.getIntroducedOn(), newVal)) {
changes.add("introducedOn: " + dm.getIntroducedOn() + " -> " + newVal);
}
dm.setIntroducedOn(newVal);
}
repository.save(dm);
writeAudit("UPDATE", dm, changes.isEmpty() ? null : String.join("; ", changes));
}
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
return gson.toJson(result);
}
@DELETE
@Path("{id}")
public String delete(@PathParam("id") Long id) {
DenominationMaster dm = repository.findById(id).orElseThrow(() -> new RuntimeException("Denomination not found: " + id));
// soft delete: flag the row instead of removing it, so it can be restored and history is preserved
dm.setDeleted(true);
dm.setIsActive(false);
repository.save(dm);
writeAudit("DELETE", dm, null);
JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
return gson.toJson(result);
}
private void writeAudit(final String action, final DenominationMaster dm, final String changes) {
Long userId = null;
String username = null;
try {
AppUser user = this.context.authenticatedUser();
if (user != null) {
userId = user.getId();
username = user.getUsername();
}
} catch (Exception e) {
// audit should never block the main operation if the user cannot be resolved
}
auditRepository.save(DenominationMasterAudit.of(action, dm, changes, userId, username));
}
private java.util.Map<String, Object> toMap(DenominationMaster dm) {
java.util.Map<String, Object> map = new java.util.LinkedHashMap<>();
map.put("id", dm.getId());
map.put("denomination", dm.getDenomination());
map.put("kind", dm.getKind());
map.put("description", dm.getDescription());
map.put("isActive", dm.getIsActive());
map.put("introducedOn", dm.getIntroducedOn() != null ? dm.getIntroducedOn().toString() : null);
map.put("discontinuedOn", dm.getDiscontinuedOn() != null ? dm.getDiscontinuedOn().toString() : null);
map.put("deleted", dm.getDeleted());
return map;
}
private java.util.Map<String, Object> auditToMap(DenominationMasterAudit a) {
java.util.Map<String, Object> map = new java.util.LinkedHashMap<>();
map.put("id", a.getId());
map.put("denominationId", a.getDenominationId());
map.put("action", a.getAction());
map.put("denomination", a.getDenomination());
map.put("kind", a.getKind());
map.put("description", a.getDescription());
map.put("isActive", a.getIsActive());
map.put("introducedOn", a.getIntroducedOn() != null ? a.getIntroducedOn().toString() : null);
map.put("discontinuedOn", a.getDiscontinuedOn() != null ? a.getDiscontinuedOn().toString() : null);
map.put("changes", a.getChanges());
map.put("performedBy", a.getPerformedBy());
map.put("performedByUsername", a.getPerformedByUsername());
map.put("performedOn", a.getPerformedOn() != null ? a.getPerformedOn().toString() : null);
return map;
}
}
@@ -0,0 +1,62 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.
*/
package org.apache.fineract.portfolio.denominationmaster.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_denomination_master")
@Getter
@Setter
@NoArgsConstructor
public class DenominationMaster {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "denomination", nullable = false)
private Integer denomination;
@Column(name = "kind", nullable = false, length = 10)
private String kind;
@Column(name = "description", length = 100)
private String description;
@Column(name = "is_active", nullable = false)
private Boolean isActive = true;
@Column(name = "introduced_on")
private LocalDate introducedOn;
@Column(name = "discontinued_on")
private LocalDate discontinuedOn;
@Column(name = "deleted", nullable = false)
private Boolean deleted = false;
public static DenominationMaster create(Integer denomination, String kind, String description,
Boolean isActive, LocalDate introducedOn, LocalDate discontinuedOn) {
DenominationMaster dm = new DenominationMaster();
dm.denomination = denomination;
dm.kind = kind;
dm.description = description;
dm.isActive = isActive != null ? isActive : true;
dm.introducedOn = introducedOn;
dm.discontinuedOn = discontinuedOn;
dm.deleted = false;
return dm;
}
}
@@ -0,0 +1,83 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.
*/
package org.apache.fineract.portfolio.denominationmaster.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_denomination_master_audit")
@Getter
@Setter
@NoArgsConstructor
public class DenominationMasterAudit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "denomination_id")
private Long denominationId;
@Column(name = "action", nullable = false, length = 20)
private String action;
@Column(name = "denomination")
private Integer denomination;
@Column(name = "kind", length = 10)
private String kind;
@Column(name = "description", length = 100)
private String description;
@Column(name = "is_active")
private Boolean isActive;
@Column(name = "introduced_on")
private LocalDate introducedOn;
@Column(name = "discontinued_on")
private LocalDate discontinuedOn;
@Column(name = "changes", length = 1000)
private String changes;
@Column(name = "performed_by")
private Long performedBy;
@Column(name = "performed_by_username", length = 100)
private String performedByUsername;
@Column(name = "performed_on", nullable = false)
private LocalDateTime performedOn;
public static DenominationMasterAudit of(final String action, final DenominationMaster dm, final String changes,
final Long performedBy, final String performedByUsername) {
final DenominationMasterAudit a = new DenominationMasterAudit();
a.action = action;
a.denominationId = dm.getId();
a.denomination = dm.getDenomination();
a.kind = dm.getKind();
a.description = dm.getDescription();
a.isActive = dm.getIsActive();
a.introducedOn = dm.getIntroducedOn();
a.discontinuedOn = dm.getDiscontinuedOn();
a.changes = changes;
a.performedBy = performedBy;
a.performedByUsername = performedByUsername;
a.performedOn = LocalDateTime.now();
return a;
}
}
@@ -0,0 +1,17 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.
*/
package org.apache.fineract.portfolio.denominationmaster.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface DenominationMasterAuditRepository extends JpaRepository<DenominationMasterAudit, Long> {
List<DenominationMasterAudit> findByDenominationIdOrderByPerformedOnDesc(Long denominationId);
List<DenominationMasterAudit> findAllByOrderByPerformedOnDesc();
}
@@ -0,0 +1,15 @@
package org.apache.fineract.portfolio.denominationmaster.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface DenominationMasterRepository extends JpaRepository<DenominationMaster, Long> {
List<DenominationMaster> findByDeletedFalseOrderByKindDescDenominationDesc();
List<DenominationMaster> findByIsActiveTrueAndDeletedFalseOrderByKindDescDenominationDesc();
List<DenominationMaster> findByDeletedTrueOrderByKindDescDenominationDesc();
boolean existsByDenominationAndKindAndDeletedFalse(Integer denomination, String kind);
boolean existsByDenominationAndKindAndDeletedFalseAndIdNot(Integer denomination, String kind, Long id);
}
@@ -0,0 +1,93 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.denominationmaster.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_savings_txn_denomination")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SavingsTxnDenomination {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "txn_id", nullable = false)
private Long txnId;
@Column(name = "savings_account_id", nullable = false)
private Long savingsAccountId;
@Column(name = "account_no", length = 50)
private String accountNo;
@Column(name = "client_id")
private Long clientId;
@Column(name = "cif_no", length = 50)
private String cifNo;
@Column(name = "denomination_id", nullable = false)
private Long denominationId;
@Column(name = "denomination_value", nullable = false)
private Integer denominationValue;
@Column(name = "count", nullable = false)
private Integer count;
@Column(name = "total_amount", nullable = false, precision = 19, scale = 6)
private BigDecimal totalAmount;
@Column(name = "txn_date")
private LocalDate txnDate;
@Column(name = "created_at")
private LocalDateTime createdAt;
/** "DEPOSIT" or "WITHDRAWAL" — identifies the direction of the cash flow. */
@Column(name = "txn_type", nullable = false, length = 20)
private String txnType;
/** ID of the logged-in teller / officer who performed the transaction. */
@Column(name = "officer_id")
private Long officerId;
/** Username of the teller / officer who performed the transaction. */
@Column(name = "officer_username", length = 100)
private String officerUsername;
}
@@ -0,0 +1,15 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.
*/
package org.apache.fineract.portfolio.denominationmaster.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SavingsTxnDenominationRepository extends JpaRepository<SavingsTxnDenomination, Long> {
List<SavingsTxnDenomination> findByTxnId(Long txnId);
List<SavingsTxnDenomination> findBySavingsAccountId(Long savingsAccountId);
}
@@ -0,0 +1,349 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements.
*/
package org.apache.fineract.portfolio.denominationmaster.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.denominationmaster.domain.SavingsTxnDenomination;
import org.apache.fineract.portfolio.denominationmaster.domain.SavingsTxnDenominationRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Validates and persists the per-transaction cash denomination breakdown sent by the UI
* on both deposits and withdrawals.
*/
@Service
@RequiredArgsConstructor
public class DenominationBreakdownWriter {
private final SavingsTxnDenominationRepository repository;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
// ── Public API ────────────────────────────────────────────────────────────
/**
* Full validation of the denomination breakdown:
* <ol>
* <li>Each item must have a non-null {@code denominationId}.</li>
* <li>Each item's {@code count} must be > 0.</li>
* <li>Each item's {@code denominationValue} (or {@code denomination}) must be > 0.</li>
* <li>The grand total (sum of value × count) must equal {@code expectedAmount}.</li>
* </ol>
* No-op when no breakdown is provided.
*
* @param command the JSON command containing the denominationBreakdown array
* @param expectedAmount the transaction amount that the breakdown must sum to
* @param txnType "DEPOSIT" or "WITHDRAWAL" — used in error messages
*/
public void validateBreakdown(final JsonCommand command, final BigDecimal expectedAmount, final String txnType) {
if (command == null || !command.parameterExists("denominationBreakdown")) {
return;
}
final JsonArray breakdown = command.arrayOfParameterNamed("denominationBreakdown");
if (breakdown == null || breakdown.size() == 0) {
return;
}
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource("denominationBreakdown");
BigDecimal computedTotal = BigDecimal.ZERO;
final String label = (txnType != null && !txnType.isBlank()) ? txnType.toUpperCase() : "TRANSACTION";
for (int i = 0; i < breakdown.size(); i++) {
final JsonObject obj = breakdown.get(i).getAsJsonObject();
final String itemPrefix = "denominationBreakdown[" + i + "]";
// 1. denominationId is required
if (!obj.has("denominationId") || obj.get("denominationId").isJsonNull()) {
validator.reset().parameter(itemPrefix + ".denominationId")
.value(null)
.failWithCode("required", "denominationId is required for each denomination item.");
continue; // cannot compute further without ID
}
final long denominationId = obj.get("denominationId").getAsLong();
if (denominationId <= 0) {
validator.reset().parameter(itemPrefix + ".denominationId")
.value(denominationId)
.failWithCode("invalid", "denominationId must be a positive number.");
}
// 2. count is required and must be > 0
if (!obj.has("count") || obj.get("count").isJsonNull()) {
validator.reset().parameter(itemPrefix + ".count")
.value(null)
.failWithCode("required", "count is required for each denomination item.");
continue;
}
final int count = obj.get("count").getAsInt();
if (count <= 0) {
// count = 0 is silently skipped (UI may send zero for unselected rows)
continue;
}
// 3. denominationValue (or denomination) must be present and > 0
int denominationValue = 0;
if (obj.has("denominationValue") && !obj.get("denominationValue").isJsonNull()) {
denominationValue = obj.get("denominationValue").getAsInt();
} else if (obj.has("denomination") && !obj.get("denomination").isJsonNull()) {
denominationValue = obj.get("denomination").getAsInt();
}
if (denominationValue <= 0) {
validator.reset().parameter(itemPrefix + ".denominationValue")
.value(denominationValue)
.failWithCode("invalid",
"denominationValue must be greater than zero for denomination item " + i + ".");
continue;
}
// 4. Accumulate total: value × count
computedTotal = computedTotal.add(BigDecimal.valueOf((long) denominationValue * count));
}
// Throw item-level validation errors first
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist",
"Denomination breakdown validation errors exist.", errors);
}
// 5. Grand total must match the transaction amount
if (expectedAmount != null && computedTotal.compareTo(expectedAmount) != 0) {
throw new PlatformDataIntegrityException(
"error.msg.denomination.total.mismatch",
label + " denomination breakdown total (" + computedTotal
+ ") does not match the transaction amount (" + expectedAmount
+ "). Please re-check the note/coin counts.");
}
}
/**
* Convenience wrapper kept for backward-compatibility with any callers that do not
* yet pass a {@code txnType}. Delegates to {@link #validateBreakdown}.
*/
public void validateTotal(final JsonCommand command, final BigDecimal expectedAmount) {
validateBreakdown(command, expectedAmount, "TRANSACTION");
}
/**
* Validates that the cashier currently holds enough of each denomination
* in the withdrawal breakdown. Auto-detects the cashier from the authenticated
* user. If the user is not linked to a cashier (e.g. admin/back-office), the
* check is skipped silently.
*
* @param command the JSON command containing the denominationBreakdown array
*/
public void validateWithdrawalStock(final JsonCommand command) {
if (command == null || !command.parameterExists("denominationBreakdown")) return;
final JsonArray breakdown = command.arrayOfParameterNamed("denominationBreakdown");
if (breakdown == null || breakdown.size() == 0) return;
Long cashierId = null;
try {
final AppUser user = context.authenticatedUser();
cashierId = jdbcTemplate.queryForObject(
"SELECT mc.id FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?",
Long.class, user.getId());
} catch (Exception e) {
return; // not a cashier user — skip stock check
}
if (cashierId == null) return;
final Map<Long, Long> stock = getCashierNetPosition(cashierId);
final List<String> shortages = new ArrayList<>();
for (final JsonElement el : breakdown) {
final JsonObject item = el.getAsJsonObject();
final int count = item.has("count") && !item.get("count").isJsonNull() ? item.get("count").getAsInt() : 0;
if (count <= 0) continue;
if (!item.has("denominationId") || item.get("denominationId").isJsonNull()) continue;
final long denomId = item.get("denominationId").getAsLong();
final long available = stock.getOrDefault(denomId, 0L);
if (count > available) {
final int denomValue = item.has("denominationValue") && !item.get("denominationValue").isJsonNull()
? item.get("denominationValue").getAsInt() : 0;
shortages.add("₹" + denomValue + " (need " + count + ", available " + available + ")");
}
}
if (!shortages.isEmpty()) {
throw new PlatformDataIntegrityException(
"error.msg.cashier.denomination.insufficient",
"Insufficient denomination stock for withdrawal: " + String.join("; ", shortages));
}
}
/**
* Computes the cashier's current net denomination-wise stock by summing all IN
* transactions and subtracting all OUT transactions for that cashier.
*
* @param cashierId the cashier whose position to compute
* @return map of denominationId → net count available
*/
public Map<Long, Long> getCashierNetPosition(final Long cashierId) {
final String sql = "SELECT denomination_id, SUM(net_count) AS available FROM ("
+ " SELECT ctd.denomination_id, CAST(SUM(ctd.count) AS BIGINT) AS net_count"
+ " FROM m_cash_transfer_detail ctd JOIN m_cash_transfer ct ON ct.id = ctd.transfer_id"
+ " WHERE ct.to_cashier_id = ? AND (ct.is_reversed IS NULL OR ct.is_reversed = false)"
+ " GROUP BY ctd.denomination_id"
+ " UNION ALL"
+ " SELECT icd.denomination_id, CAST(SUM(icd.count) AS BIGINT)"
+ " FROM m_cash_inter_cashier_detail icd JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
+ " WHERE ic.to_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false)"
+ " GROUP BY icd.denomination_id"
+ " UNION ALL"
+ " SELECT std.denomination_id, CAST(SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'DEPOSIT'"
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id"
+ " UNION ALL"
+ " SELECT ced.denomination_id, CAST(SUM(ced.count) AS BIGINT)"
+ " FROM m_cash_exchange_detail ced JOIN m_cash_exchange ce ON ce.id = ced.exchange_id"
+ " WHERE ce.cashier_id = ? AND ced.direction = 'RECEIVED'"
+ " AND (ce.is_reversed IS NULL OR ce.is_reversed = false)"
+ " GROUP BY ced.denomination_id"
+ " UNION ALL"
+ " SELECT csd.denomination_id, CAST(-SUM(csd.count) AS BIGINT)"
+ " FROM m_cash_settlement_detail csd JOIN m_cash_settlement cs ON cs.id = csd.settlement_id"
+ " WHERE cs.from_cashier_id = ? AND (cs.is_reversed IS NULL OR cs.is_reversed = false)"
+ " GROUP BY csd.denomination_id"
+ " UNION ALL"
+ " SELECT icd.denomination_id, CAST(-SUM(icd.count) AS BIGINT)"
+ " FROM m_cash_inter_cashier_detail icd JOIN m_cash_inter_cashier ic ON ic.id = icd.inter_cashier_id"
+ " WHERE ic.from_cashier_id = ? AND (ic.is_reversed IS NULL OR ic.is_reversed = false)"
+ " GROUP BY icd.denomination_id"
+ " UNION ALL"
+ " SELECT std.denomination_id, CAST(-SUM(std.count) AS BIGINT)"
+ " FROM m_savings_txn_denomination std"
+ " WHERE std.txn_type = 'WITHDRAWAL'"
+ " AND std.officer_id IN (SELECT au.id FROM m_appuser au"
+ " JOIN m_cashiers mc ON mc.staff_id = au.staff_id WHERE mc.id = ?)"
+ " GROUP BY std.denomination_id"
+ " UNION ALL"
+ " SELECT ced.denomination_id, CAST(-SUM(ced.count) AS BIGINT)"
+ " FROM m_cash_exchange_detail ced JOIN m_cash_exchange ce ON ce.id = ced.exchange_id"
+ " WHERE ce.cashier_id = ? AND ced.direction = 'GIVEN'"
+ " AND (ce.is_reversed IS NULL OR ce.is_reversed = false)"
+ " GROUP BY ced.denomination_id"
+ ") t GROUP BY denomination_id";
final Map<Long, Long> stock = new HashMap<>();
jdbcTemplate.query(sql, rs -> {
stock.put(rs.getLong("denomination_id"), rs.getLong("available"));
}, cashierId, cashierId, cashierId, cashierId, cashierId, cashierId, cashierId, cashierId);
return stock;
}
/**
* Persist each valid denomination row for the given transaction.
* <p>
* Items with {@code count <= 0} are silently skipped (UI sends zero for
* unselected rows). Items missing {@code denominationId} are also skipped
* (they should have already been rejected by {@link #validateBreakdown}).
* <p>
* The logged-in officer (teller) is automatically resolved from the
* security context; if resolution fails (e.g. batch job) the officer fields
* are left {@code null}.
*
* @param command the JSON command containing the denominationBreakdown array
* @param txnId ID of the saved savings / current / RD / FD transaction
* @param savingsAccountId savings / current / RD / FD account ID
* @param accountNo account number string
* @param clientId client ID (may be null for group accounts)
* @param cifNo CIF number (may be null)
* @param txnDate transaction date
* @param txnType "DEPOSIT" or "WITHDRAWAL"
*/
public void save(final JsonCommand command, final Long txnId, final Long savingsAccountId, final String accountNo,
final Long clientId, final String cifNo, final LocalDate txnDate, final String txnType) {
if (command == null || txnId == null || !command.parameterExists("denominationBreakdown")) {
return;
}
final JsonArray breakdown = command.arrayOfParameterNamed("denominationBreakdown");
if (breakdown == null || breakdown.size() == 0) {
return;
}
// Resolve the officer / teller from the security context
Long officerId = null;
String officerUsername = null;
try {
final AppUser user = this.context.authenticatedUser();
if (user != null) {
officerId = user.getId();
officerUsername = user.getUsername();
}
} catch (final Exception ignored) {
// If user cannot be resolved (e.g. batch / async job), leave officer fields null
}
final String resolvedTxnType = (txnType != null && !txnType.isBlank()) ? txnType.toUpperCase() : "DEPOSIT";
for (final JsonElement entry : breakdown) {
final JsonObject obj = entry.getAsJsonObject();
// denominationId is mandatory (NOT NULL in DB)
if (!obj.has("denominationId") || obj.get("denominationId").isJsonNull() || !obj.has("count")) {
continue;
}
final int count = obj.get("count").getAsInt();
if (count <= 0) {
continue; // zero-count rows sent by UI are silently ignored
}
final long denominationId = obj.get("denominationId").getAsLong();
// Resolve denomination face value
int denominationValue = 0;
if (obj.has("denominationValue") && !obj.get("denominationValue").isJsonNull()) {
denominationValue = obj.get("denominationValue").getAsInt();
} else if (obj.has("denomination") && !obj.get("denomination").isJsonNull()) {
denominationValue = obj.get("denomination").getAsInt();
}
final BigDecimal lineTotal = BigDecimal.valueOf((long) denominationValue * count);
final SavingsTxnDenomination row = SavingsTxnDenomination.builder()
.txnId(txnId)
.savingsAccountId(savingsAccountId)
.accountNo(accountNo)
.clientId(clientId)
.cifNo(cifNo)
.denominationId(denominationId)
.denominationValue(denominationValue)
.count(count)
.totalAmount(lineTotal)
.txnDate(txnDate)
.createdAt(LocalDateTime.now())
.txnType(resolvedTxnType)
.officerId(officerId)
.officerUsername(officerUsername)
.build();
this.repository.save(row);
}
}
}
@@ -67,7 +67,7 @@ public class DepositAccountTransactionDataValidator {
routingCodeParamName, receiptNumberParamName, bankNumberParamName, DepositsApiConstants.amountParamName, routingCodeParamName, receiptNumberParamName, bankNumberParamName, DepositsApiConstants.amountParamName,
DepositsApiConstants.accountIdParamName, DepositsApiConstants.dateParamName, DepositsApiConstants.submittedOnDateParamName, DepositsApiConstants.accountIdParamName, DepositsApiConstants.dateParamName, DepositsApiConstants.submittedOnDateParamName,
DepositsApiConstants.lienTransaction, DepositsApiConstants.isManualTransaction, DepositsApiConstants.chargesPaidByData, DepositsApiConstants.lienTransaction, DepositsApiConstants.isManualTransaction, DepositsApiConstants.chargesPaidByData,
DepositsApiConstants.accountNoParamName, DepositsApiConstants.noteParamName)); DepositsApiConstants.accountNoParamName, DepositsApiConstants.noteParamName, "denominationBreakdown"));
private static final Set<String> DEPOSIT_ACCOUNT_RECOMMENDED_DEPOSIT_AMOUNT_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>( private static final Set<String> DEPOSIT_ACCOUNT_RECOMMENDED_DEPOSIT_AMOUNT_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName,
@@ -31,6 +31,8 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.deposit
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.expectedFirstDepositOnDateParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.expectedFirstDepositOnDateParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isCalendarInheritedParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isCalendarInheritedParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isMandatoryDepositParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isMandatoryDepositParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isSeniorCitizenParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.seniorCitizenInterestRateParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.mandatoryRecommendedDepositAmountParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.mandatoryRecommendedDepositAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maturityInstructionIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maturityInstructionIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferInterestToSavingsParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferInterestToSavingsParamName;
@@ -230,6 +232,16 @@ public class DepositAccountAssembler {
interestRate = product.nominalAnnualInterestRate(); interestRate = product.nominalAnnualInterestRate();
} }
// Senior citizen interest rate override: use the higher rate when applicable
if (command.parameterExists(isSeniorCitizenParamName) && command.booleanPrimitiveValueOfParameterNamed(isSeniorCitizenParamName)) {
if (command.parameterExists(seniorCitizenInterestRateParamName)) {
final BigDecimal scRate = command.bigDecimalValueOfParameterNamed(seniorCitizenInterestRateParamName);
if (scRate != null && scRate.compareTo(BigDecimal.ZERO) > 0) {
interestRate = scRate;
}
}
}
SavingsCompoundingInterestPeriodType interestCompoundingPeriodType = null; SavingsCompoundingInterestPeriodType interestCompoundingPeriodType = null;
final Integer interestPeriodTypeValue = command.integerValueOfParameterNamed(interestCompoundingPeriodTypeParamName); final Integer interestPeriodTypeValue = command.integerValueOfParameterNamed(interestCompoundingPeriodTypeParamName);
if (interestPeriodTypeValue != null) { if (interestPeriodTypeValue != null) {
@@ -22,7 +22,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
@Entity @Entity
@Table(name = "m_fd_base_interest_rate_effective") @Table(name = "m_cbs_base_interest_rate_effective")
@Getter @Getter
@NoArgsConstructor @NoArgsConstructor
public class FdBaseInterestRateEffective { public class FdBaseInterestRateEffective {
@@ -23,7 +23,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
@Entity @Entity
@Table(name = "m_fd_base_interest_rate_slab_history") @Table(name = "m_cbs_base_interest_rate_slab_history")
@Getter @Getter
@NoArgsConstructor @NoArgsConstructor
public class FdBaseInterestRateSlabHistory { public class FdBaseInterestRateSlabHistory {
@@ -182,6 +182,12 @@ public class FixedDepositAccount extends SavingsAccount {
final BigDecimal depositAmount = accountTermAndPreClosure.depositAmount(); final BigDecimal depositAmount = accountTermAndPreClosure.depositAmount();
applicableInterestRate = this.chart.getApplicableInterestRate(depositAmount, depositStartDate(), depositCloseDate, this.client); applicableInterestRate = this.chart.getApplicableInterestRate(depositAmount, depositStartDate(), depositCloseDate, this.client);
// CBS: override with senior citizen rate if applicable
if (isSeniorCitizen() && getSeniorCitizenInterestRate() != null
&& getSeniorCitizenInterestRate().compareTo(BigDecimal.ZERO) > 0) {
applicableInterestRate = getSeniorCitizenInterestRate();
}
if (applyPreMaturePenalty) { if (applyPreMaturePenalty) {
applicableInterestRate = applicableInterestRate.subtract(penalInterest); applicableInterestRate = applicableInterestRate.subtract(penalInterest);
applicableInterestRate = applicableInterestRate.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : applicableInterestRate; applicableInterestRate = applicableInterestRate.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : applicableInterestRate;
@@ -192,6 +198,16 @@ public class FixedDepositAccount extends SavingsAccount {
return applicableInterestRate.divide(BigDecimal.valueOf(100L), mc); return applicableInterestRate.divide(BigDecimal.valueOf(100L), mc);
} }
@Override
public BigDecimal getEffectiveInterestRateAsFractionAccrual(final MathContext mc, final LocalDate upToInterestCalculationDate) {
// CBS: accrual must also use senior citizen rate to stay consistent with maturity calculation
if (isSeniorCitizen() && getSeniorCitizenInterestRate() != null
&& getSeniorCitizenInterestRate().compareTo(BigDecimal.ZERO) > 0) {
return getSeniorCitizenInterestRate().divide(BigDecimal.valueOf(100L), mc);
}
return super.getEffectiveInterestRateAsFractionAccrual(mc, upToInterestCalculationDate);
}
public void updateMaturityDateAndAmountBeforeAccountActivation(final MathContext mc, final boolean isPreMatureClosure, public void updateMaturityDateAndAmountBeforeAccountActivation(final MathContext mc, final boolean isPreMatureClosure,
final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) {
List<SavingsAccountTransaction> allTransactions = new ArrayList<>(); List<SavingsAccountTransaction> allTransactions = new ArrayList<>();
@@ -234,6 +234,12 @@ public class RecurringDepositAccount extends SavingsAccount {
if (this.chart != null) { if (this.chart != null) {
applicableInterestRate = this.chart.getApplicableInterestRate(depositAmount, depositStartDate(), depositCloseDate, this.client); applicableInterestRate = this.chart.getApplicableInterestRate(depositAmount, depositStartDate(), depositCloseDate, this.client);
// CBS: override with senior citizen rate if applicable
if (isSeniorCitizen() && getSeniorCitizenInterestRate() != null
&& getSeniorCitizenInterestRate().compareTo(BigDecimal.ZERO) > 0) {
applicableInterestRate = getSeniorCitizenInterestRate();
}
if (applyPreMaturePenalty) { if (applyPreMaturePenalty) {
applicableInterestRate = applicableInterestRate.subtract(penalInterest); applicableInterestRate = applicableInterestRate.subtract(penalInterest);
applicableInterestRate = applicableInterestRate.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : applicableInterestRate; applicableInterestRate = applicableInterestRate.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : applicableInterestRate;
@@ -245,6 +251,16 @@ public class RecurringDepositAccount extends SavingsAccount {
return applicableInterestRate.divide(BigDecimal.valueOf(100L), mc); return applicableInterestRate.divide(BigDecimal.valueOf(100L), mc);
} }
@Override
public BigDecimal getEffectiveInterestRateAsFractionAccrual(final MathContext mc, final LocalDate upToInterestCalculationDate) {
// CBS: accrual must also use senior citizen rate to stay consistent with maturity calculation
if (isSeniorCitizen() && getSeniorCitizenInterestRate() != null
&& getSeniorCitizenInterestRate().compareTo(BigDecimal.ZERO) > 0) {
return getSeniorCitizenInterestRate().divide(BigDecimal.valueOf(100L), mc);
}
return super.getEffectiveInterestRateAsFractionAccrual(mc, upToInterestCalculationDate);
}
public void updateMaturityDateAndAmount(final MathContext mc, final boolean isPreMatureClosure, public void updateMaturityDateAndAmount(final MathContext mc, final boolean isPreMatureClosure,
final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) {
final LocalDate maturityDate = calculateMaturityDate(); final LocalDate maturityDate = calculateMaturityDate();
@@ -42,6 +42,8 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.specialC
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.weakerSectionParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.weakerSectionParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominationFacilityParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominationFacilityParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebitFacilityParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebitFacilityParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isSeniorCitizenParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.seniorCitizenInterestRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.allowOverdraftParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.allowOverdraftParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.clientIdParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.clientIdParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.enforceMinRequiredBalanceParamName;
@@ -394,6 +396,22 @@ public class SavingsAccountAssembler {
account.setAutoDebitFacility(this.fromApiJsonHelper.extractBooleanNamed(autoDebitFacilityParamName, element)); account.setAutoDebitFacility(this.fromApiJsonHelper.extractBooleanNamed(autoDebitFacilityParamName, element));
} }
// Senior citizen rate — runs after validateDomainRules so it is the final persisted value
if (command.parameterExists(isSeniorCitizenParamName)) {
final Boolean scFlag = this.fromApiJsonHelper.extractBooleanNamed(isSeniorCitizenParamName, element);
if (scFlag != null) {
account.setSeniorCitizen(scFlag);
if (Boolean.TRUE.equals(scFlag) && command.parameterExists(seniorCitizenInterestRateParamName)) {
final BigDecimal scRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(
seniorCitizenInterestRateParamName, element);
if (scRate != null && scRate.compareTo(BigDecimal.ZERO) > 0) {
account.setSeniorCitizenInterestRate(scRate);
account.updateNominalAnnualInterestRate(scRate);
}
}
}
}
account.setGuardianName(this.fromApiJsonHelper.extractStringNamed(guardianNameParamName, element)); account.setGuardianName(this.fromApiJsonHelper.extractStringNamed(guardianNameParamName, element));
account.setGuardianCifNumber(this.fromApiJsonHelper.extractStringNamed(guardianCifNumberParamName, element)); account.setGuardianCifNumber(this.fromApiJsonHelper.extractStringNamed(guardianCifNumberParamName, element));
account.setGuardianRelationship(this.fromApiJsonHelper.extractStringNamed(guardianRelationshipParamName, element)); account.setGuardianRelationship(this.fromApiJsonHelper.extractStringNamed(guardianRelationshipParamName, element));
@@ -139,8 +139,10 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
} }
this.savingsAccountRepository.save(account); this.savingsAccountRepository.save(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, transactionBooleanValues.isAccountTransfer(), if (!transactionBooleanValues.isSkipAccountingPostings()) {
backdatedTxnsAllowedTill); postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds,
transactionBooleanValues.isAccountTransfer(), backdatedTxnsAllowedTill);
}
businessEventNotifierService.notifyPostBusinessEvent(new SavingsWithdrawalBusinessEvent(withdrawal)); businessEventNotifierService.notifyPostBusinessEvent(new SavingsWithdrawalBusinessEvent(withdrawal));
return withdrawal; return withdrawal;
@@ -153,13 +155,32 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
final boolean isAccountTransfer, final boolean isRegularTransaction, final boolean backdatedTxnsAllowedTill) { final boolean isAccountTransfer, final boolean isRegularTransaction, final boolean backdatedTxnsAllowedTill) {
final SavingsAccountTransactionType savingsAccountTransactionType = SavingsAccountTransactionType.DEPOSIT; final SavingsAccountTransactionType savingsAccountTransactionType = SavingsAccountTransactionType.DEPOSIT;
return handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, return handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction,
savingsAccountTransactionType, backdatedTxnsAllowedTill); savingsAccountTransactionType, backdatedTxnsAllowedTill, false);
}
@Override
public SavingsAccountTransaction handleDeposit(final SavingsAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean isAccountTransfer, final boolean isRegularTransaction, final boolean backdatedTxnsAllowedTill,
final boolean skipAccountingPostings) {
final SavingsAccountTransactionType savingsAccountTransactionType = SavingsAccountTransactionType.DEPOSIT;
return handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction,
savingsAccountTransactionType, backdatedTxnsAllowedTill, skipAccountingPostings);
} }
private SavingsAccountTransaction handleDeposit(final SavingsAccount account, final DateTimeFormatter fmt, private SavingsAccountTransaction handleDeposit(final SavingsAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail, final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean isAccountTransfer, final boolean isRegularTransaction, final boolean isAccountTransfer, final boolean isRegularTransaction,
final SavingsAccountTransactionType savingsAccountTransactionType, final boolean backdatedTxnsAllowedTill) { final SavingsAccountTransactionType savingsAccountTransactionType, final boolean backdatedTxnsAllowedTill) {
return handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction,
savingsAccountTransactionType, backdatedTxnsAllowedTill, false);
}
private SavingsAccountTransaction handleDeposit(final SavingsAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean isAccountTransfer, final boolean isRegularTransaction,
final SavingsAccountTransactionType savingsAccountTransactionType, final boolean backdatedTxnsAllowedTill,
final boolean skipAccountingPostings) {
context.authenticatedUser(); context.authenticatedUser();
account.validateForAccountBlock(); account.validateForAccountBlock();
account.validateForCreditBlock(); account.validateForCreditBlock();
@@ -210,7 +231,9 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
this.savingsAccountRepository.saveAndFlush(account); this.savingsAccountRepository.saveAndFlush(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer, backdatedTxnsAllowedTill); if (!skipAccountingPostings) {
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer, backdatedTxnsAllowedTill);
}
businessEventNotifierService.notifyPostBusinessEvent(new SavingsDepositBusinessEvent(deposit)); businessEventNotifierService.notifyPostBusinessEvent(new SavingsDepositBusinessEvent(deposit));
return deposit; return deposit;
} }
@@ -43,6 +43,7 @@ import org.apache.fineract.portfolio.currentaccount.exception.CurrentAccountNotF
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountTransactionWritePlatformService; import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountTransactionWritePlatformService;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct; import org.apache.fineract.portfolio.currentproduct.domain.CurrentProduct;
import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository; import org.apache.fineract.portfolio.currentproduct.domain.CurrentProductRepository;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -60,6 +61,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
private final CurrentProductRepository currentProductRepository; private final CurrentProductRepository currentProductRepository;
private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository; private final ProductToGLAccountMappingRepository productToGLAccountMappingRepository;
private final JournalEntryRepository journalEntryRepository; private final JournalEntryRepository journalEntryRepository;
private final DenominationBreakdownWriter denominationBreakdownWriter;
@Transactional @Transactional
@Override @Override
@@ -74,6 +76,7 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount"); final BigDecimal amount = command.bigDecimalValueOfParameterNamed("transactionAmount");
validatePositiveAmount(amount, accountId, "deposit"); validatePositiveAmount(amount, accountId, "deposit");
this.denominationBreakdownWriter.validateBreakdown(command, amount, "DEPOSIT");
final String refNo = command.stringValueOfParameterNamed("accountNumber"); final String refNo = command.stringValueOfParameterNamed("accountNumber");
@@ -85,6 +88,11 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
currentAccountTransactionRepository.saveAndFlush(txn); currentAccountTransactionRepository.saveAndFlush(txn);
this.denominationBreakdownWriter.save(command, txn.getId(), account.getId(), account.getAccountNo(),
account.getClient() != null ? account.getClient().getId() : null,
account.getClient() != null ? account.getClient().getCifNumber() : null, txnDate,
"DEPOSIT");
account.setAccountBalanceDerived(newBalance); account.setAccountBalanceDerived(newBalance);
account.setTotalDepositsDerived(safeBalance(account.getTotalDepositsDerived()).add(amount)); account.setTotalDepositsDerived(safeBalance(account.getTotalDepositsDerived()).add(amount));
currentAccountRepository.save(account); currentAccountRepository.save(account);
@@ -135,6 +143,14 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
account.setTotalWithdrawalsDerived(safeBalance(account.getTotalWithdrawalsDerived()).add(amount)); account.setTotalWithdrawalsDerived(safeBalance(account.getTotalWithdrawalsDerived()).add(amount));
currentAccountRepository.save(account); currentAccountRepository.save(account);
// Validate denomination stock availability then validate totals before saving breakdown
this.denominationBreakdownWriter.validateWithdrawalStock(command);
this.denominationBreakdownWriter.validateBreakdown(command, amount, "WITHDRAWAL");
this.denominationBreakdownWriter.save(command, txn.getId(), account.getId(), account.getAccountNo(),
account.getClient() != null ? account.getClient().getId() : null,
account.getClient() != null ? account.getClient().getCifNumber() : null, txnDate,
"WITHDRAWAL");
// Withdrawal GL: DR SAVINGS_CONTROL (liability down), CR SAVINGS_REFERENCE (cash out) // Withdrawal GL: DR SAVINGS_CONTROL (liability down), CR SAVINGS_REFERENCE (cash out)
postTransactionGl(account, txn, JournalEntryType.CREDIT, JournalEntryType.DEBIT, "Current Account Withdrawal"); postTransactionGl(account, txn, JournalEntryType.CREDIT, JournalEntryType.DEBIT, "Current Account Withdrawal");
@@ -78,6 +78,7 @@ import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.exception.ClientNotActiveException; import org.apache.fineract.portfolio.client.exception.ClientNotActiveException;
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.apache.fineract.portfolio.group.domain.Group; import org.apache.fineract.portfolio.group.domain.Group;
import org.apache.fineract.portfolio.group.exception.GroupNotActiveException; import org.apache.fineract.portfolio.group.exception.GroupNotActiveException;
import org.apache.fineract.portfolio.note.domain.Note; import org.apache.fineract.portfolio.note.domain.Note;
@@ -138,6 +139,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
private final HolidayRepositoryWrapper holidayRepository; private final HolidayRepositoryWrapper holidayRepository;
private final WorkingDaysRepositoryWrapper workingDaysRepository; private final WorkingDaysRepositoryWrapper workingDaysRepository;
private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository; private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository;
private final DenominationBreakdownWriter denominationBreakdownWriter;
@Transactional @Transactional
@Override @Override
@@ -360,9 +362,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final Map<String, Object> changes = new LinkedHashMap<>(); final Map<String, Object> changes = new LinkedHashMap<>();
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes); final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
this.denominationBreakdownWriter.validateBreakdown(command, transactionAmount, "DEPOSIT");
final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleFDDeposit(account, fmt, transactionDate, final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleFDDeposit(account, fmt, transactionDate,
transactionAmount, paymentDetail); transactionAmount, paymentDetail);
this.denominationBreakdownWriter.save(command, deposit.getId(), account.getId(), account.getAccountNumber(),
account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate,
"DEPOSIT");
return new CommandProcessingResultBuilder().withEntityId(deposit.getId()).withOfficeId(account.officeId()) return new CommandProcessingResultBuilder().withEntityId(deposit.getId()).withOfficeId(account.officeId())
.withClientId(account.clientId()).withGroupId(account.groupId()).withSavingsId(savingsId).with(changes).build(); .withClientId(account.clientId()).withGroupId(account.groupId()).withSavingsId(savingsId).with(changes).build();
@@ -417,9 +424,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final Map<String, Object> changes = new LinkedHashMap<>(); final Map<String, Object> changes = new LinkedHashMap<>();
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes); final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
this.denominationBreakdownWriter.validateBreakdown(command, transactionAmount, "DEPOSIT");
final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleRDDeposit(account, fmt, transactionDate, final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleRDDeposit(account, fmt, transactionDate,
transactionAmount, paymentDetail, isRegularTransaction); transactionAmount, paymentDetail, isRegularTransaction);
this.denominationBreakdownWriter.save(command, deposit.getId(), account.getId(), account.getAccountNumber(),
account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate,
"DEPOSIT");
return new CommandProcessingResultBuilder() // return new CommandProcessingResultBuilder() //
.withEntityId(deposit.getId()) // .withEntityId(deposit.getId()) //
.withOfficeId(account.officeId()) // .withOfficeId(account.officeId()) //
@@ -460,6 +472,13 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final SavingsAccountTransaction withdrawal = this.depositAccountDomainService.handleWithdrawal(account, fmt, transactionDate, final SavingsAccountTransaction withdrawal = this.depositAccountDomainService.handleWithdrawal(account, fmt, transactionDate,
transactionAmount, paymentDetail, true, isRegularTransaction); transactionAmount, paymentDetail, true, isRegularTransaction);
// Validate denomination stock availability then validate totals before saving breakdown
this.denominationBreakdownWriter.validateWithdrawalStock(command);
this.denominationBreakdownWriter.validateBreakdown(command, transactionAmount, "WITHDRAWAL");
this.denominationBreakdownWriter.save(command, withdrawal.getId(), account.getId(), account.getAccountNumber(),
account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate,
"WITHDRAWAL");
return new CommandProcessingResultBuilder() // return new CommandProcessingResultBuilder() //
.withEntityId(withdrawal.getId()) // .withEntityId(withdrawal.getId()) //
.withOfficeId(account.officeId()) // .withOfficeId(account.officeId()) //
@@ -35,6 +35,7 @@ import io.github.resilience4j.retry.annotation.Retry;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.MathContext; import java.math.MathContext;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
@@ -88,6 +89,7 @@ import org.apache.fineract.portfolio.account.service.AccountTransfersReadPlatfor
import org.apache.fineract.portfolio.charge.domain.Charge; import org.apache.fineract.portfolio.charge.domain.Charge;
import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper; import org.apache.fineract.portfolio.charge.domain.ChargeRepositoryWrapper;
import org.apache.fineract.portfolio.charge.domain.ChargeTimeType; import org.apache.fineract.portfolio.charge.domain.ChargeTimeType;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.exception.ClientNotActiveException; import org.apache.fineract.portfolio.client.exception.ClientNotActiveException;
import org.apache.fineract.portfolio.group.domain.Group; import org.apache.fineract.portfolio.group.domain.Group;
@@ -167,6 +169,7 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
private final SavingsAccountInterestPostingService savingsAccountInterestPostingService; private final SavingsAccountInterestPostingService savingsAccountInterestPostingService;
private final ErrorHandler errorHandler; private final ErrorHandler errorHandler;
private final FacilityChargeAutoApplyService facilityChargeAutoApplyService; private final FacilityChargeAutoApplyService facilityChargeAutoApplyService;
private final DenominationBreakdownWriter denominationBreakdownWriter;
@Transactional @Transactional
@Override @Override
@@ -325,6 +328,7 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount"); final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount");
this.savingsAccountTransactionDataValidator.validateTransactionWithPivotDate(transactionDate, account); this.savingsAccountTransactionDataValidator.validateTransactionWithPivotDate(transactionDate, account);
this.denominationBreakdownWriter.validateBreakdown(command, transactionAmount, "DEPOSIT");
final Map<String, Object> changes = new LinkedHashMap<>(); final Map<String, Object> changes = new LinkedHashMap<>();
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes); final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
@@ -333,6 +337,11 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(account, fmt, transactionDate, final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(account, fmt, transactionDate,
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill); transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
// Save denomination breakdown for DEPOSIT via shared writer
this.denominationBreakdownWriter.save(command, deposit.getId(), account.getId(), account.getAccountNumber(),
account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null,
transactionDate, "DEPOSIT");
if (isGsim && (deposit.getId() != null)) { if (isGsim && (deposit.getId() != null)) {
log.debug("Deposit account has been created: {} ", deposit); log.debug("Deposit account has been created: {} ", deposit);
@@ -413,9 +422,15 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
BigDecimal currentBalance = gsim.getParentDeposit().subtract(transactionAmount); BigDecimal currentBalance = gsim.getParentDeposit().subtract(transactionAmount);
gsim.setParentDeposit(currentBalance); gsim.setParentDeposit(currentBalance);
gsimRepository.save(gsim); gsimRepository.save(gsim);
} }
// Validate denomination stock availability then validate totals before saving breakdown
this.denominationBreakdownWriter.validateWithdrawalStock(command);
this.denominationBreakdownWriter.validateBreakdown(command, transactionAmount, "WITHDRAWAL");
this.denominationBreakdownWriter.save(command, withdrawal.getId(), account.getId(), account.getAccountNumber(),
account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null,
transactionDate, "WITHDRAWAL");
final String noteText = command.stringValueOfParameterNamed("note"); final String noteText = command.stringValueOfParameterNamed("note");
if (StringUtils.isNotBlank(noteText)) { if (StringUtils.isNotBlank(noteText)) {
final Note note = Note.savingsTransactionNote(account, withdrawal, noteText); final Note note = Note.savingsTransactionNote(account, withdrawal, noteText);
@@ -98,6 +98,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityConfig
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimit; import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimit;
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimitRepository; import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimitRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsProductRepository; import org.apache.fineract.portfolio.savings.domain.SavingsProductRepository;
import org.apache.fineract.portfolio.denominationmaster.domain.SavingsTxnDenominationRepository;
import org.apache.fineract.portfolio.savings.service.FacilityChargeAutoApplyService; import org.apache.fineract.portfolio.savings.service.FacilityChargeAutoApplyService;
import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformService; import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformService;
import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformServiceImpl; import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformServiceImpl;
@@ -107,6 +108,7 @@ import org.apache.fineract.portfolio.savings.service.DepositAccountPreMatureCalc
import org.apache.fineract.portfolio.savings.service.DepositAccountPreMatureCalculationPlatformServiceImpl; import org.apache.fineract.portfolio.savings.service.DepositAccountPreMatureCalculationPlatformServiceImpl;
import org.apache.fineract.portfolio.savings.service.DepositAccountReadPlatformService; import org.apache.fineract.portfolio.savings.service.DepositAccountReadPlatformService;
import org.apache.fineract.portfolio.savings.service.DepositAccountReadPlatformServiceImpl; import org.apache.fineract.portfolio.savings.service.DepositAccountReadPlatformServiceImpl;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService; import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformServiceJpaRepositoryImpl; import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.savings.service.DepositApplicationProcessWritePlatformService; import org.apache.fineract.portfolio.savings.service.DepositApplicationProcessWritePlatformService;
@@ -267,7 +269,8 @@ public class SavingsConfiguration {
DepositAccountReadPlatformService depositAccountReadPlatformService, CalendarInstanceRepository calendarInstanceRepository, DepositAccountReadPlatformService depositAccountReadPlatformService, CalendarInstanceRepository calendarInstanceRepository,
ConfigurationDomainService configurationDomainService, HolidayRepositoryWrapper holidayRepository, ConfigurationDomainService configurationDomainService, HolidayRepositoryWrapper holidayRepository,
WorkingDaysRepositoryWrapper workingDaysRepository, WorkingDaysRepositoryWrapper workingDaysRepository,
DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
DenominationBreakdownWriter denominationBreakdownWriter
) { ) {
return new DepositAccountWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper, return new DepositAccountWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper,
@@ -276,7 +279,8 @@ public class SavingsConfiguration {
journalEntryWritePlatformService, depositAccountDomainService, noteRepository, accountTransfersReadPlatformService, journalEntryWritePlatformService, depositAccountDomainService, noteRepository, accountTransfersReadPlatformService,
chargeRepository, savingsAccountChargeRepository, accountAssociationsReadPlatformService, chargeRepository, savingsAccountChargeRepository, accountAssociationsReadPlatformService,
accountTransfersWritePlatformService, depositAccountReadPlatformService, calendarInstanceRepository, accountTransfersWritePlatformService, depositAccountReadPlatformService, calendarInstanceRepository,
configurationDomainService, holidayRepository, workingDaysRepository, depositAccountOnHoldTransactionRepository); configurationDomainService, holidayRepository, workingDaysRepository, depositAccountOnHoldTransactionRepository,
denominationBreakdownWriter);
} }
@Bean @Bean
@@ -424,7 +428,8 @@ public class SavingsConfiguration {
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository, EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService, StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService, GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
ErrorHandler errorHandler, FacilityChargeAutoApplyService facilityChargeAutoApplyService) { ErrorHandler errorHandler, FacilityChargeAutoApplyService facilityChargeAutoApplyService,
DenominationBreakdownWriter denominationBreakdownWriter) {
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper, return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper,
staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator, staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator,
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService, savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
@@ -432,7 +437,7 @@ public class SavingsConfiguration {
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService, chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository, depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository,
standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService, standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService,
errorHandler, facilityChargeAutoApplyService); errorHandler, facilityChargeAutoApplyService, denominationBreakdownWriter);
} }
@Bean @Bean
@@ -0,0 +1,57 @@
package org.apache.fineract.portfolio.teller.service;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class GlDispatchService {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new Gson();
/**
* Vault → Cashier GL entry. Runs in its own transaction so a failure
* never rolls back the caller's (already-committed) transfer record.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void allocateCashToCashier(Long tellerId, Long cashierId,
BigDecimal amount, String currencyCode, String txnDate, String notes) {
JsonObject body = new JsonObject();
body.addProperty("locale", "en");
body.addProperty("dateFormat", "yyyy-MM-dd");
body.addProperty("txnDate", txnDate);
body.addProperty("txnAmount", amount);
body.addProperty("currencyCode", currencyCode);
body.addProperty("txnNote", notes != null ? notes : "");
CommandWrapper wrapper = new CommandWrapperBuilder().withJson(gson.toJson(body))
.allocateCashToCashier(tellerId, cashierId).build();
commandsSourceWritePlatformService.logCommandSource(wrapper);
}
/**
* Cashier → Vault GL entry (settlement / transfer reversal).
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void settleCashFromCashier(Long tellerId, Long cashierId,
BigDecimal amount, String currencyCode, String txnDate, String notes) {
JsonObject body = new JsonObject();
body.addProperty("locale", "en");
body.addProperty("dateFormat", "yyyy-MM-dd");
body.addProperty("txnDate", txnDate);
body.addProperty("txnAmount", amount);
body.addProperty("currencyCode", currencyCode);
body.addProperty("txnNote", notes != null ? notes : "");
CommandWrapper wrapper = new CommandWrapperBuilder().withJson(gson.toJson(body))
.settleCashFromCashier(tellerId, cashierId).build();
commandsSourceWritePlatformService.logCommandSource(wrapper);
}
}
@@ -316,4 +316,15 @@
<include file="parts/0297_fix_unique_nominee_per_account.xml" relativeToChangelogFile="true"/> <include file="parts/0297_fix_unique_nominee_per_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0298_unique_external_id_on_current_account.xml" relativeToChangelogFile="true"/> <include file="parts/0298_unique_external_id_on_current_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0299_seed_indian_states.xml" relativeToChangelogFile="true"/> <include file="parts/0299_seed_indian_states.xml" relativeToChangelogFile="true"/>
<include file="parts/0300_rename_fd_base_interest_rate_tables_to_cbs.xml" relativeToChangelogFile="true"/>
<include file="parts/0301_add_current_account_to_transfer_details.xml" relativeToChangelogFile="true"/>
<include file="parts/0012_denomination_master.xml" relativeToChangelogFile="true"/>
<include file="parts/0013_cash_exchange.xml" relativeToChangelogFile="true"/>
<include file="parts/0014_cash_transfer.xml" relativeToChangelogFile="true"/>
<include file="parts/0015_cash_transfer_add_ids.xml" relativeToChangelogFile="true"/>
<include file="parts/0016_cash_settlement.xml" relativeToChangelogFile="true"/>
<include file="parts/0017_cash_inter_cashier.xml" relativeToChangelogFile="true"/>
<include file="parts/0018_cash_reconciliation.xml" relativeToChangelogFile="true"/>
<include file="parts/0019_cash_reversal_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0020_cash_permissions.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,204 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="denomination_master_table_001" author="CBS">
<createTable tableName="m_denomination_master">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="denomination" type="INT">
<constraints nullable="false"/>
</column>
<column name="kind" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="description" type="VARCHAR(100)"/>
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="introduced_on" type="DATE"/>
<column name="discontinued_on" type="DATE"/>
</createTable>
</changeSet>
<changeSet id="denomination_master_initial_data_001" author="CBS">
<insert tableName="m_denomination_master">
<column name="denomination" value="2000"/>
<column name="kind" value="note"/>
<column name="description" value="2000 Rupee Note"/>
<column name="is_active" valueBoolean="false"/>
<column name="introduced_on" value="2016-11-08"/>
<column name="discontinued_on" value="2023-09-30"/>
</insert>
<insert tableName="m_denomination_master">
<column name="denomination" value="500"/>
<column name="kind" value="note"/>
<column name="description" value="500 Rupee Note"/>
<column name="is_active" valueBoolean="true"/>
<column name="introduced_on" value="2016-11-08"/>
</insert>
<insert tableName="m_denomination_master">
<column name="denomination" value="200"/>
<column name="kind" value="note"/>
<column name="description" value="200 Rupee Note"/>
<column name="is_active" valueBoolean="true"/>
<column name="introduced_on" value="2017-08-25"/>
</insert>
<insert tableName="m_denomination_master">
<column name="denomination" value="100"/>
<column name="kind" value="note"/>
<column name="description" value="100 Rupee Note"/>
<column name="is_active" valueBoolean="true"/>
<column name="introduced_on" value="2018-07-19"/>
</insert>
<insert tableName="m_denomination_master">
<column name="denomination" value="50"/>
<column name="kind" value="note"/>
<column name="description" value="50 Rupee Note"/>
<column name="is_active" valueBoolean="true"/>
<column name="introduced_on" value="2017-08-25"/>
</insert>
<insert tableName="m_denomination_master">
<column name="denomination" value="20"/>
<column name="kind" value="note"/>
<column name="description" value="20 Rupee Note"/>
<column name="is_active" valueBoolean="true"/>
<column name="introduced_on" value="2019-08-26"/>
</insert>
<insert tableName="m_denomination_master">
<column name="denomination" value="10"/>
<column name="kind" value="note"/>
<column name="description" value="10 Rupee Note"/>
<column name="is_active" valueBoolean="true"/>
<column name="introduced_on" value="2018-01-01"/>
</insert>
<insert tableName="m_denomination_master">
<column name="denomination" value="20"/>
<column name="kind" value="coin"/>
<column name="description" value="20 Rupee Coin"/>
<column name="is_active" valueBoolean="true"/>
<column name="introduced_on" value="2019-01-01"/>
</insert>
<insert tableName="m_denomination_master">
<column name="denomination" value="10"/>
<column name="kind" value="coin"/>
<column name="description" value="10 Rupee Coin"/>
<column name="is_active" valueBoolean="true"/>
<column name="introduced_on" value="2009-01-01"/>
</insert>
<insert tableName="m_denomination_master">
<column name="denomination" value="5"/>
<column name="kind" value="coin"/>
<column name="description" value="5 Rupee Coin"/>
<column name="is_active" valueBoolean="true"/>
<column name="introduced_on" value="2009-01-01"/>
</insert>
<insert tableName="m_denomination_master">
<column name="denomination" value="2"/>
<column name="kind" value="coin"/>
<column name="description" value="2 Rupee Coin"/>
<column name="is_active" valueBoolean="true"/>
<column name="introduced_on" value="2009-01-01"/>
</insert>
<insert tableName="m_denomination_master">
<column name="denomination" value="1"/>
<column name="kind" value="coin"/>
<column name="description" value="1 Rupee Coin"/>
<column name="is_active" valueBoolean="true"/>
<column name="introduced_on" value="2009-01-01"/>
</insert>
</changeSet>
<changeSet id="savings_txn_denomination_table_001" author="CBS">
<createTable tableName="m_savings_txn_denomination">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="txn_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="savings_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="account_no" type="VARCHAR(50)"/>
<column name="client_id" type="BIGINT"/>
<column name="cif_no" type="VARCHAR(50)"/>
<column name="denomination_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="denomination_value" type="INT">
<constraints nullable="false"/>
</column>
<column name="count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="total_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="txn_date" type="DATE"/>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
</createTable>
</changeSet>
<changeSet id="denomination_master_audit_table_001" author="CBS">
<createTable tableName="m_denomination_master_audit">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="denomination_id" type="BIGINT"/>
<column name="action" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="denomination" type="INT"/>
<column name="kind" type="VARCHAR(10)"/>
<column name="description" type="VARCHAR(100)"/>
<column name="is_active" type="BOOLEAN"/>
<column name="introduced_on" type="DATE"/>
<column name="discontinued_on" type="DATE"/>
<column name="changes" type="VARCHAR(1000)"/>
<column name="performed_by" type="BIGINT"/>
<column name="performed_by_username" type="VARCHAR(100)"/>
<column name="performed_on" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="m_denomination_master_audit" indexName="idx_denom_audit_denom_id">
<column name="denomination_id"/>
</createIndex>
</changeSet>
<changeSet id="denomination_master_soft_delete_001" author="CBS">
<addColumn tableName="m_denomination_master">
<column name="deleted" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<!-- =====================================================================
Migration: Add txn_type, officer_id, officer_username to
m_savings_txn_denomination.
txn_type distinguishes DEPOSIT from WITHDRAWAL.
officer_id / officer_username record who performed the transaction.
===================================================================== -->
<changeSet id="savings_txn_denomination_add_txn_type_officer_001" author="CBS">
<addColumn tableName="m_savings_txn_denomination">
<column name="txn_type" type="VARCHAR(20)" defaultValue="DEPOSIT">
<constraints nullable="false"/>
</column>
<column name="officer_id" type="BIGINT"/>
<column name="officer_username" type="VARCHAR(100)"/>
</addColumn>
<createIndex tableName="m_savings_txn_denomination"
indexName="idx_savings_txn_denom_txntype">
<column name="txn_type"/>
</createIndex>
<createIndex tableName="m_savings_txn_denomination"
indexName="idx_savings_txn_denom_officer">
<column name="officer_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="cash_exchange_table_001" author="CBS">
<createTable tableName="m_cash_exchange">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="account_id" type="BIGINT"/>
<column name="account_no" type="VARCHAR(50)"/>
<column name="client_id" type="BIGINT"/>
<column name="exchange_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="exchange_date" type="DATE"/>
<column name="note" type="VARCHAR(500)"/>
<column name="performed_by" type="BIGINT"/>
<column name="performed_by_username" type="VARCHAR(100)"/>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
</createTable>
</changeSet>
<changeSet id="cash_exchange_detail_table_001" author="CBS">
<createTable tableName="m_cash_exchange_detail">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="exchange_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="direction" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="denomination_id" type="BIGINT"/>
<column name="denomination_value" type="INT">
<constraints nullable="false"/>
</column>
<column name="count" type="INT">
<constraints nullable="false"/>
</column>
<column name="total_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="m_cash_exchange_detail" indexName="idx_cash_exchange_detail_eid">
<column name="exchange_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="cash_transfer_table_001" author="CBS">
<createTable tableName="m_cash_transfer">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="transfer_date" type="DATE"/>
<column name="from_party" type="VARCHAR(100)"/>
<column name="to_party" type="VARCHAR(100)"/>
<column name="transfer_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="note" type="VARCHAR(500)"/>
<column name="performed_by" type="BIGINT"/>
<column name="performed_by_username" type="VARCHAR(100)"/>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
</createTable>
</changeSet>
<changeSet id="cash_transfer_detail_table_001" author="CBS">
<createTable tableName="m_cash_transfer_detail">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="transfer_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="denomination_id" type="BIGINT"/>
<column name="denomination_value" type="INT">
<constraints nullable="false"/>
</column>
<column name="count" type="INT">
<constraints nullable="false"/>
</column>
<column name="total_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="m_cash_transfer_detail" indexName="idx_cash_transfer_detail_tid">
<column name="transfer_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="cash_transfer_add_party_ids_001" author="CBS">
<addColumn tableName="m_cash_transfer">
<column name="from_teller_id" type="BIGINT"/>
<column name="to_cashier_id" type="BIGINT"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="cash_settlement_table_001" author="CBS">
<createTable tableName="m_cash_settlement">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="settlement_date" type="DATE"/>
<column name="from_cashier_id" type="BIGINT"/>
<column name="to_teller_id" type="BIGINT"/>
<column name="from_party" type="VARCHAR(100)"/>
<column name="to_party" type="VARCHAR(100)"/>
<column name="settlement_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="note" type="VARCHAR(500)"/>
<column name="performed_by" type="BIGINT"/>
<column name="performed_by_username" type="VARCHAR(100)"/>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
<column name="is_reversed" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="reversed_by" type="BIGINT"/>
<column name="reversed_at" type="TIMESTAMP"/>
<column name="reverse_reason" type="VARCHAR(500)"/>
</createTable>
</changeSet>
<changeSet id="cash_settlement_detail_table_001" author="CBS">
<createTable tableName="m_cash_settlement_detail">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="settlement_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="denomination_id" type="BIGINT"/>
<column name="denomination_value" type="INT">
<constraints nullable="false"/>
</column>
<column name="count" type="INT">
<constraints nullable="false"/>
</column>
<column name="total_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="m_cash_settlement_detail" indexName="idx_cash_settlement_detail_sid">
<column name="settlement_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="cash_inter_cashier_table_001" author="CBS">
<createTable tableName="m_cash_inter_cashier">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="transfer_date" type="DATE"/>
<column name="teller_id" type="BIGINT"/>
<column name="from_cashier_id" type="BIGINT"/>
<column name="to_cashier_id" type="BIGINT"/>
<column name="from_party" type="VARCHAR(100)"/>
<column name="to_party" type="VARCHAR(100)"/>
<column name="transfer_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="note" type="VARCHAR(500)"/>
<column name="performed_by" type="BIGINT"/>
<column name="performed_by_username" type="VARCHAR(100)"/>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
<column name="is_reversed" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="reversed_by" type="BIGINT"/>
<column name="reversed_at" type="TIMESTAMP"/>
<column name="reverse_reason" type="VARCHAR(500)"/>
</createTable>
</changeSet>
<changeSet id="cash_inter_cashier_detail_table_001" author="CBS">
<createTable tableName="m_cash_inter_cashier_detail">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="inter_cashier_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="denomination_id" type="BIGINT"/>
<column name="denomination_value" type="INT">
<constraints nullable="false"/>
</column>
<column name="count" type="INT">
<constraints nullable="false"/>
</column>
<column name="total_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="m_cash_inter_cashier_detail" indexName="idx_cash_inter_cashier_detail_pid">
<column name="inter_cashier_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="cash_reconciliation_table_001" author="CBS">
<createTable tableName="m_cash_reconciliation">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="recon_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="cashier_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="cashier_name" type="VARCHAR(150)"/>
<column name="teller_id" type="BIGINT"/>
<column name="expected_amount" type="DECIMAL(19,6)" defaultValueNumeric="0"/>
<column name="physical_amount" type="DECIMAL(19,6)" defaultValueNumeric="0"/>
<column name="variance" type="DECIMAL(19,6)" defaultValueNumeric="0"/>
<column name="status" type="VARCHAR(10)" defaultValue="OK"/>
<column name="note" type="VARCHAR(500)"/>
<column name="performed_by" type="BIGINT"/>
<column name="performed_by_username" type="VARCHAR(100)"/>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
</createTable>
<createIndex tableName="m_cash_reconciliation" indexName="idx_cash_recon_cashier_date">
<column name="cashier_id"/>
<column name="recon_date"/>
</createIndex>
</changeSet>
<changeSet id="cash_reconciliation_detail_table_001" author="CBS">
<createTable tableName="m_cash_reconciliation_detail">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="reconciliation_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="denomination_id" type="BIGINT"/>
<column name="denomination_value" type="INT">
<constraints nullable="false"/>
</column>
<column name="expected_count" type="INT" defaultValueNumeric="0"/>
<column name="physical_count" type="INT" defaultValueNumeric="0"/>
<column name="variance_count" type="INT" defaultValueNumeric="0"/>
<column name="variance_amount" type="DECIMAL(19,6)" defaultValueNumeric="0"/>
</createTable>
<createIndex tableName="m_cash_reconciliation_detail" indexName="idx_cash_recon_detail_rid">
<column name="reconciliation_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="cash_transfer_reversal_cols_001" author="CBS">
<addColumn tableName="m_cash_transfer">
<column name="is_reversed" type="BOOLEAN" defaultValueBoolean="false"/>
<column name="reversed_by" type="BIGINT"/>
<column name="reversed_at" type="TIMESTAMP"/>
<column name="reverse_reason" type="VARCHAR(500)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="cash_module_permissions_001" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'CREATE_CASHTRANSFER', 'CASHTRANSFER', 'CREATE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CASHTRANSFER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'READ_CASHTRANSFER', 'CASHTRANSFER', 'READ', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CASHTRANSFER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'REVERSE_CASHTRANSFER', 'CASHTRANSFER', 'REVERSE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REVERSE_CASHTRANSFER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'CREATE_CASHSETTLEMENT', 'CASHSETTLEMENT', 'CREATE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CASHSETTLEMENT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'READ_CASHSETTLEMENT', 'CASHSETTLEMENT', 'READ', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CASHSETTLEMENT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'REVERSE_CASHSETTLEMENT', 'CASHSETTLEMENT', 'REVERSE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REVERSE_CASHSETTLEMENT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'CREATE_CASHINTERCASHIER', 'CASHINTERCASHIER', 'CREATE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CASHINTERCASHIER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'READ_CASHINTERCASHIER', 'CASHINTERCASHIER', 'READ', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CASHINTERCASHIER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'REVERSE_CASHINTERCASHIER', 'CASHINTERCASHIER', 'REVERSE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REVERSE_CASHINTERCASHIER');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'CREATE_CASHRECONCILIATION', 'CASHRECONCILIATION', 'CREATE', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CASHRECONCILIATION');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'cashier', 'READ_CASHRECONCILIATION', 'CASHRECONCILIATION', 'READ', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CASHRECONCILIATION');
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0300-1" author="cbs">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_fd_base_interest_rate_effective"/>
</preConditions>
<renameTable oldTableName="m_fd_base_interest_rate_effective"
newTableName="m_cbs_base_interest_rate_effective"/>
</changeSet>
<changeSet id="0300-2" author="cbs">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_fd_base_interest_rate_slab_history"/>
</preConditions>
<renameTable oldTableName="m_fd_base_interest_rate_slab_history"
newTableName="m_cbs_base_interest_rate_slab_history"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Add from_current_account_id column to m_account_transfer_details -->
<changeSet id="0301-1" author="cbs">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_account_transfer_details" columnName="from_current_account_id"/>
</not>
</preConditions>
<addColumn tableName="m_account_transfer_details">
<column name="from_current_account_id" type="BIGINT">
<constraints nullable="true"
foreignKeyName="FK_atd_from_current_account"
references="m_current_account(id)"/>
</column>
</addColumn>
</changeSet>
<!-- Add to_current_account_id column to m_account_transfer_details -->
<changeSet id="0301-2" author="cbs">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_account_transfer_details" columnName="to_current_account_id"/>
</not>
</preConditions>
<addColumn tableName="m_account_transfer_details">
<column name="to_current_account_id" type="BIGINT">
<constraints nullable="true"
foreignKeyName="FK_atd_to_current_account"
references="m_current_account(id)"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -25,15 +25,22 @@ public class SavingsTransactionBooleanValues {
private final boolean isApplyWithdrawFee; private final boolean isApplyWithdrawFee;
private final boolean isInterestTransfer; private final boolean isInterestTransfer;
private final boolean isExceptionForBalanceCheck; private final boolean isExceptionForBalanceCheck;
private final boolean skipAccountingPostings;
public SavingsTransactionBooleanValues(final boolean isAccountTransfer, final boolean isRegularTransaction, public SavingsTransactionBooleanValues(final boolean isAccountTransfer, final boolean isRegularTransaction,
final boolean isApplyWithdrawFee, final boolean isInterestTransfer, final boolean isExceptionForBalanceCheck) { final boolean isApplyWithdrawFee, final boolean isInterestTransfer, final boolean isExceptionForBalanceCheck) {
this(isAccountTransfer, isRegularTransaction, isApplyWithdrawFee, isInterestTransfer, isExceptionForBalanceCheck, false);
}
public SavingsTransactionBooleanValues(final boolean isAccountTransfer, final boolean isRegularTransaction,
final boolean isApplyWithdrawFee, final boolean isInterestTransfer, final boolean isExceptionForBalanceCheck,
final boolean skipAccountingPostings) {
this.isAccountTransfer = isAccountTransfer; this.isAccountTransfer = isAccountTransfer;
this.isRegularTransaction = isRegularTransaction; this.isRegularTransaction = isRegularTransaction;
this.isApplyWithdrawFee = isApplyWithdrawFee; this.isApplyWithdrawFee = isApplyWithdrawFee;
this.isInterestTransfer = isInterestTransfer; this.isInterestTransfer = isInterestTransfer;
this.isExceptionForBalanceCheck = isExceptionForBalanceCheck; this.isExceptionForBalanceCheck = isExceptionForBalanceCheck;
this.skipAccountingPostings = skipAccountingPostings;
} }
public boolean isAccountTransfer() { public boolean isAccountTransfer() {
@@ -56,4 +63,8 @@ public class SavingsTransactionBooleanValues {
return this.isExceptionForBalanceCheck; return this.isExceptionForBalanceCheck;
} }
public boolean isSkipAccountingPostings() {
return this.skipAccountingPostings;
}
} }
@@ -59,7 +59,8 @@ public class SavingsAccountConstant extends SavingsApiConstants {
dateFormatParamName, transactionDateParamName, transactionAmountParamName, paymentTypeIdParamName, dateFormatParamName, transactionDateParamName, transactionAmountParamName, paymentTypeIdParamName,
transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName, bankNumberParamName, transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName, bankNumberParamName,
retailEntriesParamName, childAccountIdParamName, noteParamName, amountParamName, dateParamName, isManualTransaction, retailEntriesParamName, childAccountIdParamName, noteParamName, amountParamName, dateParamName, isManualTransaction,
lienTransaction, chargesPaidByData, submittedOnDateParamName, accountIdParamName, accountNoParamName)); lienTransaction, chargesPaidByData, submittedOnDateParamName, accountIdParamName, accountNoParamName,
"denominationBreakdown"));
protected static final Set<String> SAVINGS_ACCOUNT_TRANSACTION_RESPONSE_DATA_PARAMETERS = new HashSet<>( protected static final Set<String> SAVINGS_ACCOUNT_TRANSACTION_RESPONSE_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(idParamName, accountNoParamName)); Arrays.asList(idParamName, accountNoParamName));
@@ -3969,6 +3969,12 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
@Column(name = "auto_debit_facility", nullable = false, columnDefinition = "boolean default false") @Column(name = "auto_debit_facility", nullable = false, columnDefinition = "boolean default false")
private Boolean autoDebitFacility = false; private Boolean autoDebitFacility = false;
@Column(name = "is_senior_citizen", nullable = false, columnDefinition = "boolean default false")
private boolean isSeniorCitizen = false;
@Column(name = "senior_citizen_interest_rate", scale = 6, precision = 19, nullable = true)
private BigDecimal seniorCitizenInterestRate;
@jakarta.persistence.OneToOne(cascade = jakarta.persistence.CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY) @jakarta.persistence.OneToOne(cascade = jakarta.persistence.CascadeType.ALL, mappedBy = "savingsAccount", orphanRemoval = true, fetch = FetchType.LAZY)
private SavingsAccountFacilityConfig facilityConfig; private SavingsAccountFacilityConfig facilityConfig;
@@ -4041,4 +4047,16 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
public Boolean getAutoDebitFacility() { return autoDebitFacility; } public Boolean getAutoDebitFacility() { return autoDebitFacility; }
public void setAutoDebitFacility(Boolean v) { this.autoDebitFacility = v; } public void setAutoDebitFacility(Boolean v) { this.autoDebitFacility = v; }
public boolean isSeniorCitizen() { return isSeniorCitizen; }
public void setSeniorCitizen(boolean v) { this.isSeniorCitizen = v; }
public BigDecimal getSeniorCitizenInterestRate() { return seniorCitizenInterestRate; }
public void setSeniorCitizenInterestRate(BigDecimal v) { this.seniorCitizenInterestRate = v; }
public void updateNominalAnnualInterestRate(BigDecimal rate) {
if (rate != null && rate.compareTo(BigDecimal.ZERO) > 0) {
this.nominalAnnualInterestRate = rate;
}
}
} }
@@ -38,6 +38,10 @@ public interface SavingsAccountDomainService {
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isAccountTransfer, boolean isRegularTransaction, BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isAccountTransfer, boolean isRegularTransaction,
boolean backdatedTxnsAllowedTill); boolean backdatedTxnsAllowedTill);
SavingsAccountTransaction handleDeposit(SavingsAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isAccountTransfer, boolean isRegularTransaction,
boolean backdatedTxnsAllowedTill, boolean skipAccountingPostings);
void postJournalEntries(SavingsAccount savingsAccount, Set<Long> existingTransactionIds, Set<Long> existingReversedTransactionIds, void postJournalEntries(SavingsAccount savingsAccount, Set<Long> existingTransactionIds, Set<Long> existingReversedTransactionIds,
boolean backdatedTxnsAllowedTill); boolean backdatedTxnsAllowedTill);
@@ -37,4 +37,5 @@
<include file="parts/2012_add_current_account_permissions.xml" relativeToChangelogFile="true" /> <include file="parts/2012_add_current_account_permissions.xml" relativeToChangelogFile="true" />
<include file="parts/2013_add_missing_current_account_columns.xml" relativeToChangelogFile="true" /> <include file="parts/2013_add_missing_current_account_columns.xml" relativeToChangelogFile="true" />
<include file="parts/2014_add_is_mandatory_to_joint_holders.xml" relativeToChangelogFile="true" /> <include file="parts/2014_add_is_mandatory_to_joint_holders.xml" relativeToChangelogFile="true" />
<include file="parts/2015_add_senior_citizen_to_savings_account.xml" relativeToChangelogFile="true" />
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="2015_add_senior_citizen_to_savings_account" author="CBS">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_savings_account"/>
<not><columnExists tableName="m_savings_account" columnName="is_senior_citizen"/></not>
</preConditions>
<addColumn tableName="m_savings_account">
<column name="is_senior_citizen" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="senior_citizen_interest_rate" type="DECIMAL(19,6)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>