feat(rbi-reserve,savings): add Form1/Form9 returns, interest posting report and branch-day enhancements
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
This commit is contained in:
+1
@@ -43,6 +43,7 @@ public enum JobName {
|
|||||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||||
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
|
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
|
||||||
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
|
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
|
||||||
|
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
|
||||||
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
||||||
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
||||||
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
|
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
|
||||||
|
|||||||
+45
@@ -55,4 +55,49 @@ public final class GLAccountTransactionGuard {
|
|||||||
"glAccountId", glAccountId);
|
"glAccountId", glAccountId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses to draw more out of a holding account than it holds.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A suspense or sundry account exists to hold money on its way somewhere else, so what is in it
|
||||||
|
* is the whole of what can come out. Drawing more leaves it with a negative balance, and a
|
||||||
|
* liability with a negative balance is not a small number - it says the bank owes less than
|
||||||
|
* nothing, which is not a state that exists. It also breaks the balance sheet by the overdraw,
|
||||||
|
* and the figure is small enough to sit unnoticed inside a head that totals positive.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Checked here rather than at each call site because the accounts this protects are reached by
|
||||||
|
* several features posting directly through JDBC, and a rule enforced in one of them is a rule
|
||||||
|
* the next one added will not have.
|
||||||
|
*
|
||||||
|
* @param glAccountId the account being drawn on.
|
||||||
|
* @param amount how much the caller wants to take out.
|
||||||
|
* @param what the account's role in the caller's own words, for the message a clerk reads.
|
||||||
|
*/
|
||||||
|
public static void assertSufficientBalance(final JdbcTemplate jdbcTemplate, final Long glAccountId,
|
||||||
|
final java.math.BigDecimal amount, final String what) {
|
||||||
|
if (glAccountId == null || amount == null || amount.signum() <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Credits less debits: these are liability-natured holding accounts, so the balance is what
|
||||||
|
// is still owed onward. Reversed entries and the entries they reverse both drop out - a
|
||||||
|
// reversal pair nets to nothing, and counting either side alone moves the balance by the
|
||||||
|
// whole amount.
|
||||||
|
final java.math.BigDecimal held = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0)
|
||||||
|
FROM acc_gl_journal_entry
|
||||||
|
WHERE account_id = ?
|
||||||
|
AND (reversed IS NULL OR reversed = false)
|
||||||
|
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
|
||||||
|
""", java.math.BigDecimal.class, glAccountId);
|
||||||
|
final java.math.BigDecimal balance = held == null ? java.math.BigDecimal.ZERO : held;
|
||||||
|
if (balance.compareTo(amount) < 0) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.glaccount.insufficient.balance",
|
||||||
|
what + " holds " + balance + " but " + amount + " was asked of it. Drawing the "
|
||||||
|
+ "difference would leave the account with a negative balance, which a "
|
||||||
|
+ "holding account cannot have.",
|
||||||
|
"glAccountId", glAccountId, amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-19
@@ -42,6 +42,7 @@ import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialA
|
|||||||
import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper;
|
import org.apache.fineract.accounting.financialactivityaccount.domain.FinancialActivityAccountRepositoryWrapper;
|
||||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepository;
|
||||||
|
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
|
||||||
import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO;
|
import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO;
|
||||||
import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO;
|
import org.apache.fineract.accounting.journalentry.data.ClientChargePaymentDTO;
|
||||||
import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO;
|
import org.apache.fineract.accounting.journalentry.data.ClientTransactionDTO;
|
||||||
@@ -666,30 +667,56 @@ public class AccountingProcessorHelper {
|
|||||||
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
|
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
|
||||||
.orElse(payableAccount);
|
.orElse(payableAccount);
|
||||||
|
|
||||||
// Stage 1: the expense is recognised against whatever the liability sits in right now -- the
|
// A product that names no payable falls back to Savings Control above. The chain below would
|
||||||
// named Payable account if the product has one, or straight against Savings Control if it
|
// then open with Dr Savings Control / Cr Interest On Savings and close with its exact
|
||||||
// doesn't. This was missing entirely: every product, configured or not, posted zero journal
|
// reverse, the two cancelling: the expense never charged and the member never credited. Such
|
||||||
// entries for interest until this leg existed, since a product with no Payable/Paid names
|
// a product has no liability to draw down, so it keeps the single pair it always posted.
|
||||||
// configured also collapsed stages 2 and 3 below into a same-account no-op with nothing else
|
if (payableAccount.getId().equals(controlAccount.getId())) {
|
||||||
// to post in their place.
|
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, controlAccount, savingsId, transactionId, transactionDate,
|
||||||
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, amount,
|
amount, isReversal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The interest is charged to expense and raised as a liability first, for whatever part of
|
||||||
|
// it no accrual has already covered.
|
||||||
|
//
|
||||||
|
// The chain below draws the payable DOWN, so it assumes an accrual already credited it for
|
||||||
|
// the period. Where none ran - a product posting on cash accounting, or a period the accrual
|
||||||
|
// job never covered - the payable went negative by the interest and the expense netted to
|
||||||
|
// nothing across the next two legs, leaving the charge off the P&L entirely. A liability
|
||||||
|
// account cannot hold a negative balance: the bank does not owe less than nothing.
|
||||||
|
//
|
||||||
|
// So the shortfall is raised here rather than assumed away. Where the accrual DID run, the
|
||||||
|
// payable already carries the interest and this leg writes nothing - the balance below is
|
||||||
|
// enough, and raising it again would double the liability.
|
||||||
|
//
|
||||||
|
// Only a liability payable is topped up. A bank that maps an expense GL as the payable books
|
||||||
|
// the cost there directly: payableBalance reads credit minus debit, so an expense's normal
|
||||||
|
// debit balance would come back negative and every posting would raise a shortfall larger
|
||||||
|
// than the interest itself.
|
||||||
|
final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType());
|
||||||
|
final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode);
|
||||||
|
final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO);
|
||||||
|
if (!isReversal && payableIsLiability && shortfall.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
// Dr Interest On Savings (the expense) / Cr Payable - the accrual that should have run.
|
||||||
|
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate,
|
||||||
|
shortfall, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Out of the payable, into the interest-paid account the product names.
|
||||||
|
postSavingsLeg(office, currencyCode, payableAccount, interestOnSavingsAccount, savingsId, transactionId, transactionDate, amount,
|
||||||
isReversal);
|
isReversal);
|
||||||
|
|
||||||
// The interest expense is not raised again here. It was just raised above, and this posting
|
// A separate paid account, where the product names one, sits between interest-paid and the
|
||||||
// only settles that recognised debt onward to where it is actually paid out from -- raising
|
// member; otherwise the payout leaves straight from interest-paid.
|
||||||
// the expense a second time would put the same interest on the P&L twice.
|
final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId());
|
||||||
//
|
if (hasSeparatePaidAccount) {
|
||||||
// Stage 2: the liability moves from the payable to the account it is paid out of. Where the
|
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
|
||||||
// product names no paid account both resolve to the payable and this says nothing, so it is
|
|
||||||
// skipped and the payout comes straight out of the payable.
|
|
||||||
final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId());
|
|
||||||
if (!payableIsPaid) {
|
|
||||||
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
|
|
||||||
isReversal);
|
isReversal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 3: into the member's own balance.
|
// Into the member's own balance.
|
||||||
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
|
final GLAccount payoutFromAccount = hasSeparatePaidAccount ? paidAccount : interestOnSavingsAccount;
|
||||||
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
||||||
isReversal);
|
isReversal);
|
||||||
}
|
}
|
||||||
@@ -733,6 +760,38 @@ public class AccountingProcessorHelper {
|
|||||||
*
|
*
|
||||||
* @return the account, or null where the product does not configure this name
|
* @return the account, or null where the product does not configure this name
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* What the interest payable account currently holds for this office, on its own liability side.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Read so an interest posting can tell an accrual that already ran from one that did not. The
|
||||||
|
* posting chain draws the payable down; where nothing had raised it, the account went negative
|
||||||
|
* and the expense netted to nothing, so the charge never reached the P&L at all. The caller
|
||||||
|
* raises only the shortfall, which is nil when the accrual did run.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Credits less debits, because a payable is a liability: the balance is what the bank still
|
||||||
|
* owes. Floored at zero by the caller rather than here - a negative balance is a real state to
|
||||||
|
* report, and the caller's arithmetic needs the true figure, not a clamped one.
|
||||||
|
*/
|
||||||
|
private BigDecimal payableBalance(final Office office, final GLAccount payableAccount, final String currencyCode) {
|
||||||
|
if (payableAccount == null || payableAccount.getId() == null) {
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
// Reversed entries and the entries they reverse are both excluded: a reversal pair nets to
|
||||||
|
// nothing, and counting either side alone would move the balance by the whole amount.
|
||||||
|
final BigDecimal balance = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COALESCE(SUM(CASE WHEN type_enum = 1 THEN amount ELSE -amount END), 0)
|
||||||
|
FROM acc_gl_journal_entry
|
||||||
|
WHERE account_id = ?
|
||||||
|
AND office_id = ?
|
||||||
|
AND currency_code = ?
|
||||||
|
AND (reversed IS NULL OR reversed = false)
|
||||||
|
AND id NOT IN (SELECT reversal_id FROM acc_gl_journal_entry WHERE reversal_id IS NOT NULL)
|
||||||
|
""", BigDecimal.class, payableAccount.getId(), office.getId(), currencyCode);
|
||||||
|
return balance == null ? BigDecimal.ZERO : balance;
|
||||||
|
}
|
||||||
|
|
||||||
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) {
|
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) {
|
||||||
if (savingsProductId == null) {
|
if (savingsProductId == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+10
-7
@@ -180,18 +180,21 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc
|
|||||||
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
|
// "amount > 0" guard) left the FD subledger showing the clawback while the GL never
|
||||||
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
|
// recorded it, silently going out of sync. Treating a negative amount as a mirror-image
|
||||||
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
|
// reversal -- positive magnitude, debit/credit legs swapped, same mechanism
|
||||||
// createCashBasedJournalEntriesAndReversalsForSavings already uses for isReversal -- keeps
|
// createInterestPostingJournalEntriesForSavings already uses for isReversal -- keeps the two
|
||||||
// the two in sync instead. See CashBasedAccountingProcessorForSavings for the same fix on
|
// in sync instead. See CashBasedAccountingProcessorForSavings for the same fix on the
|
||||||
// the cash-based path.
|
// cash-based path.
|
||||||
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
|
final int amountSign = savingsTransactionDTO.getAmount().compareTo(BigDecimal.ZERO);
|
||||||
if (amountSign != 0) {
|
if (amountSign != 0) {
|
||||||
final boolean isClawback = amountSign < 0;
|
final boolean isClawback = amountSign < 0;
|
||||||
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
|
final BigDecimal postingAmount = isClawback ? amount.abs() : amount;
|
||||||
final boolean effectiveIsReversal = isReversal ^ isClawback;
|
final boolean effectiveIsReversal = isReversal ^ isClawback;
|
||||||
this.helper.createCashBasedJournalEntriesAndReversalsForSavings(office, currencyCode,
|
// Routed through the same helper the cash processor uses, so a posting states
|
||||||
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), AccrualAccountsForSavings.SAVINGS_CONTROL.getValue(),
|
// the whole chain the product is configured for -- out of the payable, through
|
||||||
savingsProductId, paymentTypeId, savingsId, transactionId, transactionDate, postingAmount,
|
// the interest-paid account, into the member's balance -- rather than the single
|
||||||
effectiveIsReversal);
|
// payable-to-control pair that used to be here, which skipped interest-paid
|
||||||
|
// entirely and left the expense off the P&L.
|
||||||
|
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
|
||||||
|
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+54
@@ -46,6 +46,8 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||||
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -73,6 +75,8 @@ public class BranchDayRegisterApiResource {
|
|||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final VaultPositionService vaultPositionService;
|
private final VaultPositionService vaultPositionService;
|
||||||
|
private final SavingsInterestPostingReportService interestPostingReportService;
|
||||||
|
private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator;
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
// ── GET /v1/branchday ── the register itself ──────────────────────────────
|
// ── GET /v1/branchday ── the register itself ──────────────────────────────
|
||||||
@@ -589,6 +593,34 @@ public class BranchDayRegisterApiResource {
|
|||||||
"Business day " + businessDate + " is already closed.");
|
"Business day " + businessDate + " is already closed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── This branch's savings interest reports must be approved before it closes ──
|
||||||
|
// Not overridable by a force-close reason: the branch posts its own customers' interest when
|
||||||
|
// it closes, and a report nobody approved has nothing to post, so they would go unpaid.
|
||||||
|
final List<String> interestPending = interestPostingReportService.reportsBlockingDayClose(officeId, businessDate);
|
||||||
|
if (!interestPending.isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.branchday.interest.report.pending",
|
||||||
|
"Cannot close the day — this branch's savings interest posting report is not approved for: "
|
||||||
|
+ String.join(", ", interestPending)
|
||||||
|
+ ". Approve it under Reports → Interest Posting Reports, then close the day.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The head office closes last ──
|
||||||
|
// Every branch that opened the day closes first and posts its own interest; a branch that
|
||||||
|
// never opened is not waited for.
|
||||||
|
final boolean isHeadOffice = interestPostingReportService.isHeadOffice(officeId);
|
||||||
|
if (isHeadOffice) {
|
||||||
|
final List<String> stillOpen = jdbcTemplate.queryForList("""
|
||||||
|
SELECT o.name FROM m_branch_day_register r JOIN m_office o ON o.id = r.office_id
|
||||||
|
WHERE r.business_date = ? AND r.closed_at IS NULL AND r.office_id <> ?
|
||||||
|
ORDER BY o.name
|
||||||
|
""", String.class, Date.valueOf(businessDate), officeId);
|
||||||
|
if (!stillOpen.isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.branchday.branches.still.open",
|
||||||
|
"Cannot close the head office yet — these branches have not closed the day: " + String.join(", ", stillOpen)
|
||||||
|
+ ". The head office closes last.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Ensure every cashier holding cash has been reconciled AND settled ──
|
// ── Ensure every cashier holding cash has been reconciled AND settled ──
|
||||||
// Find all cashiers under tellers belonging to this office, check if they have a cash position,
|
// Find all cashiers under tellers belonging to this office, check if they have a cash position,
|
||||||
// and if so, verify reconciliation and settlement records exist for the business date.
|
// and if so, verify reconciliation and settlement records exist for the business date.
|
||||||
@@ -687,6 +719,28 @@ public class BranchDayRegisterApiResource {
|
|||||||
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
|
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
|
||||||
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
|
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
|
||||||
|
|
||||||
|
// ── The branch posts its own approved savings interest as it closes ──
|
||||||
|
// The head office also posts what it approved for branches that did not open today. Entries
|
||||||
|
// are raised in the customers' own branch either way.
|
||||||
|
//
|
||||||
|
// Posted here, before the close is written, and synchronously, so the outcome is never
|
||||||
|
// silent: each report posts in a transaction of its own and commits, and if any fails the
|
||||||
|
// close is refused with the reason — the day stays open, the report stays APPROVED, and
|
||||||
|
// closing again once the cause is fixed posts it.
|
||||||
|
final List<Long> toPost = new ArrayList<>(interestPostingReportService.approvedReportIds(officeId, businessDate));
|
||||||
|
if (isHeadOffice) {
|
||||||
|
toPost.addAll(interestPostingReportService.approvedReportIdsOfBranchesNotOpened(businessDate, officeId));
|
||||||
|
}
|
||||||
|
if (!toPost.isEmpty()) {
|
||||||
|
log.info("Day close {}: office {} posting {} approved savings interest report(s)", businessDate, officeId, toPost.size());
|
||||||
|
final List<String> interestFailures = savingsDailyInterestOrchestrator.postReports(toPost);
|
||||||
|
if (!interestFailures.isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.branchday.interest.posting.failed",
|
||||||
|
"Cannot close the day — savings interest could not be posted: " + String.join("; ", interestFailures)
|
||||||
|
+ ". The report is still approved; fix the cause and close the day again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and
|
// "closed_at IS NULL" in the WHERE, not just the read above: the already-closed check and
|
||||||
// this write are two separate statements, so two managers pressing Close at once (or one
|
// this write are two separate statements, so two managers pressing Close at once (or one
|
||||||
// double-click) both got past the check. The second UPDATE then overwrote the first's
|
// double-click) both got past the check. The second UPDATE then overwrote the first's
|
||||||
|
|||||||
+42
@@ -709,6 +709,21 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
+ "applied to it, so these shares have not actually been paid for and cannot be redeemed.");
|
+ "applied to it, so these shares have not actually been paid for and cannot be redeemed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Share Sundry account the redemption draws on must actually hold the money.
|
||||||
|
//
|
||||||
|
// collectionApplied above says A collection was applied to THIS account, not that the
|
||||||
|
// proceeds are still sitting in Sundry - the same holding account serves every share
|
||||||
|
// account, and an earlier redemption may already have drawn it down. Approving past that
|
||||||
|
// takes Sundry negative, which is a liability owing less than nothing, and the overdraw
|
||||||
|
// lands inside a balance-sheet head that totals positive, where it does not look wrong.
|
||||||
|
final BigDecimal redemptionValue = redemptionValueOf(shareAccountId, requestedShares);
|
||||||
|
if (redemptionValue != null) {
|
||||||
|
final Long productId = jdbcTemplate.queryForObject("SELECT product_id FROM m_share_account WHERE id = ?", Long.class,
|
||||||
|
shareAccountId);
|
||||||
|
GLAccountTransactionGuard.assertSufficientBalance(jdbcTemplate, resolveGl(productId, FA_SHARES_SUSPENSE), redemptionValue,
|
||||||
|
"The Share Sundry (Suspense) account");
|
||||||
|
}
|
||||||
|
|
||||||
final JsonObject payload = new JsonObject();
|
final JsonObject payload = new JsonObject();
|
||||||
payload.addProperty("locale", "en");
|
payload.addProperty("locale", "en");
|
||||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||||
@@ -958,6 +973,33 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
* text is only unique so long as {@code m_share_account_transactions.id} never repeats, which a
|
* text is only unique so long as {@code m_share_account_transactions.id} never repeats, which a
|
||||||
* reset id sequence in a dev environment can violate.
|
* reset id sequence in a dev environment can violate.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* What a redemption of this many shares will draw out of Share Sundry.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Priced at the unit price the member's own shares were issued at, which is the price the
|
||||||
|
* redemption itself uses. The product's nominal price is not consulted: a member who subscribed
|
||||||
|
* at an earlier price redeems at theirs, and pricing the guard differently from the posting
|
||||||
|
* would either block a redemption that fits or let one through that does not.
|
||||||
|
*
|
||||||
|
* @return the value, or null where no price can be established - the guard then stands aside
|
||||||
|
* rather than blocking on a figure it had to invent.
|
||||||
|
*/
|
||||||
|
private BigDecimal redemptionValueOf(final Long shareAccountId, final Long requestedShares) {
|
||||||
|
if (shareAccountId == null || requestedShares == null || requestedShares <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// The purchase that issued the holding - type 500. Its unit price is what the member paid.
|
||||||
|
final BigDecimal unitPrice = jdbcTemplate
|
||||||
|
.queryForList("""
|
||||||
|
SELECT unit_price FROM m_share_account_transaction
|
||||||
|
WHERE share_account_id = ? AND type_enum = 500 AND unit_price IS NOT NULL
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
""", BigDecimal.class, shareAccountId)
|
||||||
|
.stream().findFirst().orElse(null);
|
||||||
|
return unitPrice == null ? null : unitPrice.multiply(BigDecimal.valueOf(requestedShares));
|
||||||
|
}
|
||||||
|
|
||||||
private Long redeemDebitGlOf(final Long shareTransactionId) {
|
private Long redeemDebitGlOf(final Long shareTransactionId) {
|
||||||
if (shareTransactionId == null) {
|
if (shareTransactionId == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+53
-6
@@ -166,12 +166,23 @@ public class RbiFormAFilingApiResource {
|
|||||||
"The generated return must be supplied with the save.");
|
"The generated return must be supplied with the save.");
|
||||||
}
|
}
|
||||||
|
|
||||||
final LocalDate periodStart = localDate(report, "fortnightStart");
|
// The statutory form states its figures per column inside Part B rather than once at the
|
||||||
final LocalDate periodEnd = localDate(report, "fortnightEnd");
|
// top level, so the columns the saved LIST shows - NDTL, required, maintained - have to be
|
||||||
final BigDecimal ndtl = decimal(report, "ndtlAmount");
|
// read out of it. Without this a filed statutory return listed as a row of dashes, and the
|
||||||
final BigDecimal required = decimal(report, "requiredAmount");
|
// list could not be scanned for the one return somebody was looking for.
|
||||||
final BigDecimal maintained = decimal(report, "maintainedAmount");
|
final boolean statutory = report.has("formCode") && !report.get("formCode").isJsonNull()
|
||||||
final BigDecimal rate = decimal(report, "ratePercentage");
|
&& "FORM_1_STATUTORY".equals(report.get("formCode").getAsString());
|
||||||
|
|
||||||
|
// The statutory form's period runs between its two fortnight closes, which is what the
|
||||||
|
// saved list prints as the row's period.
|
||||||
|
final LocalDate periodStart = statutory ? columnDate(report, 0) : localDate(report, "fortnightStart");
|
||||||
|
final LocalDate periodEnd = statutory ? columnDate(report, 1) : localDate(report, "fortnightEnd");
|
||||||
|
// The current fortnight's figures are the second column - the close the form is made up to.
|
||||||
|
final BigDecimal ndtl = statutory ? partFigure(report, "partA", "baseDateLiabilities") : decimal(report, "ndtlAmount");
|
||||||
|
final BigDecimal required = statutory ? partFigure(report, "partB", "required") : decimal(report, "requiredAmount");
|
||||||
|
final BigDecimal maintained = statutory ? partFigure(report, "partB", "maintained")
|
||||||
|
: decimal(report, "maintainedAmount");
|
||||||
|
final BigDecimal rate = statutory ? decimal(report, "crrPercentage") : decimal(report, "ratePercentage");
|
||||||
BigDecimal average = null;
|
BigDecimal average = null;
|
||||||
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
|
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
|
||||||
average = decimal(report.getAsJsonObject("maintenance"), "average");
|
average = decimal(report.getAsJsonObject("maintenance"), "average");
|
||||||
@@ -302,4 +313,40 @@ public class RbiFormAFilingApiResource {
|
|||||||
final String value = object.get(field).getAsString();
|
final String value = object.get(field).getAsString();
|
||||||
return value == null || value.isBlank() ? null : LocalDate.parse(value);
|
return value == null || value.isBlank() ? null : LocalDate.parse(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of a statutory part's per-column figures, taken at the column the form is made up to.
|
||||||
|
*
|
||||||
|
* The form carries three columns and the saved list has one cell per figure, so it takes the
|
||||||
|
* second - the close the return reports. Index 1 rather than "the last": the third column is
|
||||||
|
* the base the ratios are charged on, not a later position, and listing it would show a
|
||||||
|
* requirement against liabilities from a fortnight nobody filed.
|
||||||
|
*/
|
||||||
|
private static BigDecimal partFigure(final JsonObject report, final String part, final String field) {
|
||||||
|
if (!report.has(part) || !report.get(part).isJsonObject()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final JsonObject partObject = report.getAsJsonObject(part);
|
||||||
|
if (!partObject.has(field) || !partObject.get(field).isJsonArray()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final var values = partObject.getAsJsonArray(field);
|
||||||
|
if (values.size() < 2 || values.get(1).isJsonNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return values.get(1).getAsBigDecimal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of the statutory form's column dates, for the period a saved row reports. */
|
||||||
|
private static LocalDate columnDate(final JsonObject report, final int index) {
|
||||||
|
if (!report.has("columns") || !report.get("columns").isJsonArray()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final var columns = report.getAsJsonArray("columns");
|
||||||
|
if (columns.size() <= index || columns.get(index).isJsonNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String value = columns.get(index).getAsString();
|
||||||
|
return value == null || value.isBlank() ? null : LocalDate.parse(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-5
@@ -199,6 +199,7 @@ public class RbiReserveConfigApiResource {
|
|||||||
ROLE_GROUP_LABELS.put("RESERVE", "Form 1 (CRR) & Form 9 (SLR)");
|
ROLE_GROUP_LABELS.put("RESERVE", "Form 1 (CRR) & Form 9 (SLR)");
|
||||||
ROLE_GROUP_LABELS.put("OSS1", "OSS 1 — Return on Assets and Liabilities");
|
ROLE_GROUP_LABELS.put("OSS1", "OSS 1 — Return on Assets and Liabilities");
|
||||||
ROLE_GROUP_LABELS.put("OSS2", "OSS 2 — Return on Earnings");
|
ROLE_GROUP_LABELS.put("OSS2", "OSS 2 — Return on Earnings");
|
||||||
|
ROLE_GROUP_LABELS.put("FORM9", "Form IX — Return on Assets and Liabilities (R408)");
|
||||||
|
|
||||||
for (final String role : ROLE_DESCRIPTIONS.keySet()) {
|
for (final String role : ROLE_DESCRIPTIONS.keySet()) {
|
||||||
if (role.startsWith("OSS1_")) {
|
if (role.startsWith("OSS1_")) {
|
||||||
@@ -227,9 +228,15 @@ public class RbiReserveConfigApiResource {
|
|||||||
*/
|
*/
|
||||||
private static final List<String> RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND");
|
private static final List<String> RATE_TYPES = List.of("CRR", "SLR", "SAVINGS_DEMAND");
|
||||||
|
|
||||||
/** The four treatments a head can be given, each naming a branch the returns already have. */
|
/**
|
||||||
|
* The treatments a head can be given, each naming a branch the returns already have.
|
||||||
|
*
|
||||||
|
* ASSET is Form IX's, and is not ASSET_NET: that one is netted OFF the liability base to arrive
|
||||||
|
* at NDTL, this one is added INTO Total Assets. A Form IX asset given ASSET_NET would be
|
||||||
|
* subtracted from the liabilities of a return that has no such base.
|
||||||
|
*/
|
||||||
private static final List<String> ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "SLR_ASSET",
|
private static final List<String> ROLE_USAGES = List.of("LIABILITY", "ASSET_NET", "MAINTAINED", "SLR_ASSET",
|
||||||
"MEMO");
|
"ASSET", "MEMO");
|
||||||
|
|
||||||
private static final Map<String, String> USAGE_DESCRIPTIONS = new LinkedHashMap<>();
|
private static final Map<String, String> USAGE_DESCRIPTIONS = new LinkedHashMap<>();
|
||||||
static {
|
static {
|
||||||
@@ -243,6 +250,27 @@ public class RbiReserveConfigApiResource {
|
|||||||
USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total");
|
USAGE_DESCRIPTIONS.put("MEMO", "Shown on the return but entering no total");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a head is consumed, in the terms of the return it belongs to.
|
||||||
|
*
|
||||||
|
* LIABILITY means two different things depending on the group. On Form 1 and Form 9 it says the
|
||||||
|
* head is added into the DTL base a ratio is charged on. On Form IX there is no ratio and no
|
||||||
|
* base: it says which SIDE OF THE BALANCE SHEET the head sits on. Describing both with the DTL
|
||||||
|
* wording would tell a Form IX filer their head feeds a calculation that does not exist on
|
||||||
|
* their return.
|
||||||
|
*/
|
||||||
|
private static String usedBy(final String group, final String usage) {
|
||||||
|
if ("FORM9".equals(group)) {
|
||||||
|
return switch (usage == null ? "" : usage) {
|
||||||
|
case "LIABILITY" -> "Form IX — a liability; enters Total Liabilities";
|
||||||
|
case "ASSET" -> "Form IX — an asset; enters Total Assets";
|
||||||
|
case "MEMO" -> "Form IX — stated on the form but entering no total";
|
||||||
|
default -> usage;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return USAGE_DESCRIPTIONS.getOrDefault(usage, usage);
|
||||||
|
}
|
||||||
|
|
||||||
/** How each built-in role is treated, so the screen can describe built-ins and additions alike. */
|
/** How each built-in role is treated, so the screen can describe built-ins and additions alike. */
|
||||||
private static final Map<String, String> BUILT_IN_USAGE = new LinkedHashMap<>();
|
private static final Map<String, String> BUILT_IN_USAGE = new LinkedHashMap<>();
|
||||||
static {
|
static {
|
||||||
@@ -390,11 +418,14 @@ public class RbiReserveConfigApiResource {
|
|||||||
o.put("label", row.get("label"));
|
o.put("label", row.get("label"));
|
||||||
o.put("group", group);
|
o.put("group", group);
|
||||||
o.put("groupLabel", ROLE_GROUP_LABELS.getOrDefault(group, group));
|
o.put("groupLabel", ROLE_GROUP_LABELS.getOrDefault(group, group));
|
||||||
o.put("usedBy", USAGE_DESCRIPTIONS.getOrDefault(usage, usage));
|
o.put("usedBy", usedBy(group, usage));
|
||||||
o.put("builtIn", false);
|
o.put("builtIn", false);
|
||||||
o.put("usage", usage);
|
o.put("usage", usage);
|
||||||
// A liability head carries the demand/time split; nothing else does.
|
// A liability head carries the demand/time split; nothing else does. Not on Form IX,
|
||||||
o.put("classified", "LIABILITY".equals(usage));
|
// where the demand and time heads are separate lines of the form itself - asking for a
|
||||||
|
// class on top of that would let a head declared under 5(a) Fixed Deposits be marked
|
||||||
|
// DEMAND, which the form has no way to report.
|
||||||
|
o.put("classified", "LIABILITY".equals(usage) && !"FORM9".equals(group));
|
||||||
o.put("roleId", row.get("id"));
|
o.put("roleId", row.get("id"));
|
||||||
o.put("accounts", byKey.getOrDefault(key, new ArrayList<>()));
|
o.put("accounts", byKey.getOrDefault(key, new ArrayList<>()));
|
||||||
out.add(o);
|
out.add(o);
|
||||||
|
|||||||
+1486
-24
File diff suppressed because it is too large
Load Diff
+59
-13
@@ -53,7 +53,10 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class RbiFormAAutoSaveService {
|
public class RbiFormAAutoSaveService {
|
||||||
|
|
||||||
private static final String FORM_CODE = "FORM_1";
|
// The statutory form, because that is the Form 1 the screen shows and the one that gets filed.
|
||||||
|
// Saving the working report's shape left rows in the list that opened as a blank statement:
|
||||||
|
// the viewer built for the statutory form found none of the fields it reads.
|
||||||
|
private static final String FORM_CODE = "FORM_1_STATUTORY";
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final RbiReserveReturnApiResource returnApiResource;
|
private final RbiReserveReturnApiResource returnApiResource;
|
||||||
@@ -89,7 +92,11 @@ public class RbiFormAAutoSaveService {
|
|||||||
try {
|
try {
|
||||||
// Computed through the same endpoint the screen uses, so an auto-saved return and one
|
// Computed through the same endpoint the screen uses, so an auto-saved return and one
|
||||||
// saved by hand cannot come out differently.
|
// saved by hand cannot come out differently.
|
||||||
report = gson.fromJson(returnApiResource.form1(today.toString(), null), JsonObject.class);
|
// No column dates passed: today IS a fortnight close - the check above returned
|
||||||
|
// otherwise - so the form derives its own pair from it, which is the pair an unattended
|
||||||
|
// run should file. A filer needing a different period enters both dates on the screen.
|
||||||
|
report = gson.fromJson(returnApiResource.form1Statutory(today.toString(), null, null, null),
|
||||||
|
JsonObject.class);
|
||||||
} catch (final RuntimeException e) {
|
} catch (final RuntimeException e) {
|
||||||
// A return that cannot be computed is not an error worth failing the batch over - an
|
// A return that cannot be computed is not an error worth failing the batch over - an
|
||||||
// unmapped role or a missing rate is a configuration gap, and the next run picks it up
|
// unmapped role or a missing rate is a configuration gap, and the next run picks it up
|
||||||
@@ -98,18 +105,22 @@ public class RbiFormAAutoSaveService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (report == null || !report.has("computable") || !report.get("computable").getAsBoolean()) {
|
// The statutory form has no all-or-nothing computable flag: an unmapped head is a real nil
|
||||||
log.info("RBI Form A auto-save: the return for {} is not computable yet; not saving a partial statement.",
|
// on it - a bank with no balances at the State Co-operative Bank files that line empty - so
|
||||||
|
// the form is filed as it stands. What it cannot do without is a rate, because every
|
||||||
|
// required amount below is a percentage of one, and saving a return whose requirement is
|
||||||
|
// zero for want of configuration would put a compliant-looking statement on the record.
|
||||||
|
if (report == null || report.get("crrPercentage") == null || report.get("crrPercentage").isJsonNull()) {
|
||||||
|
log.info("RBI Form A auto-save: no CRR rate in force on {}; not saving a return with no requirement.",
|
||||||
today);
|
today);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final LocalDate periodStart = date(report, "fortnightStart");
|
// The form's period runs between its two fortnight closes, and its headline figures sit per
|
||||||
final LocalDate periodEnd = date(report, "fortnightEnd");
|
// column inside the parts rather than once at the top.
|
||||||
BigDecimal average = null;
|
final LocalDate periodStart = column(report, 0);
|
||||||
if (report.has("maintenance") && report.get("maintenance").isJsonObject()) {
|
final LocalDate periodEnd = column(report, 1);
|
||||||
average = decimal(report.getAsJsonObject("maintenance"), "average");
|
final BigDecimal average = null;
|
||||||
}
|
|
||||||
|
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO m_rbi_form_a_filing
|
INSERT INTO m_rbi_form_a_filing
|
||||||
@@ -119,9 +130,9 @@ public class RbiFormAAutoSaveService {
|
|||||||
VALUES (?, ?, ?, ?, ?, 'DRAFT', ?, ?, ?, ?, ?, ?, ?, now())
|
VALUES (?, ?, ?, ?, ?, 'DRAFT', ?, ?, ?, ?, ?, ?, ?, now())
|
||||||
""", FORM_CODE, java.sql.Date.valueOf(today),
|
""", FORM_CODE, java.sql.Date.valueOf(today),
|
||||||
java.sql.Date.valueOf(periodEnd == null ? today : periodEnd), sqlDate(periodStart), sqlDate(periodEnd),
|
java.sql.Date.valueOf(periodEnd == null ? today : periodEnd), sqlDate(periodStart), sqlDate(periodEnd),
|
||||||
gson.toJson(report), decimal(report, "ndtlAmount"), decimal(report, "requiredAmount"),
|
gson.toJson(report), partFigure(report, "partA", "baseDateLiabilities"),
|
||||||
decimal(report, "maintainedAmount"), average, decimal(report, "ratePercentage"),
|
partFigure(report, "partB", "required"), partFigure(report, "partB", "maintained"), average,
|
||||||
"Saved automatically at the close of the fortnight.");
|
decimal(report, "crrPercentage"), "Saved automatically at the close of the fortnight.");
|
||||||
|
|
||||||
log.info("RBI Form A auto-save: saved the return for {} as a draft.", today);
|
log.info("RBI Form A auto-save: saved the return for {} as a draft.", today);
|
||||||
}
|
}
|
||||||
@@ -143,6 +154,41 @@ public class RbiFormAAutoSaveService {
|
|||||||
return object.get(field).getAsBigDecimal();
|
return object.get(field).getAsBigDecimal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of the statutory form's per-column figures, at the close the return is made up to.
|
||||||
|
*
|
||||||
|
* Index 1, the second column: the third is the base the ratios are charged on, not a later
|
||||||
|
* position, and listing it would show a requirement against a fortnight nobody filed.
|
||||||
|
*/
|
||||||
|
private static BigDecimal partFigure(final JsonObject report, final String part, final String field) {
|
||||||
|
if (!report.has(part) || !report.get(part).isJsonObject()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final JsonObject partObject = report.getAsJsonObject(part);
|
||||||
|
if (!partObject.has(field) || !partObject.get(field).isJsonArray()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final var values = partObject.getAsJsonArray(field);
|
||||||
|
if (values.size() < 2 || values.get(1).isJsonNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return values.get(1).getAsBigDecimal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of the form's column dates. */
|
||||||
|
private static LocalDate column(final JsonObject report, final int index) {
|
||||||
|
if (!report.has("columns") || !report.get("columns").isJsonArray()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final var columns = report.getAsJsonArray("columns");
|
||||||
|
if (columns.size() <= index || columns.get(index).isJsonNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String value = columns.get(index).getAsString();
|
||||||
|
return value == null || value.isBlank() ? null : LocalDate.parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
private static LocalDate date(final JsonObject object, final String field) {
|
private static LocalDate date(final JsonObject object, final String field) {
|
||||||
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
|
if (object == null || !object.has(field) || object.get(field).isJsonNull()) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+81
-70
@@ -26,10 +26,14 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.TransactionDefinition;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the day: accrue for every account, then pay out any product whose cycle ended today.
|
* Runs the day's savings interest: accrue for every account, and post interest only from approved
|
||||||
|
* interest posting reports ({@link SavingsInterestPostingReportService}).
|
||||||
*
|
*
|
||||||
* Accrual and posting are separated on purpose. Accrual touches no money and can be repeated
|
* Accrual and posting are separated on purpose. Accrual touches no money and can be repeated
|
||||||
* freely; posting moves money and must not be. Keeping them apart means a posting that fails can
|
* freely; posting moves money and must not be. Keeping them apart means a posting that fails can
|
||||||
@@ -47,91 +51,98 @@ public class SavingsDailyInterestOrchestrator {
|
|||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final SavingsDailyInterestService accrualService;
|
private final SavingsDailyInterestService accrualService;
|
||||||
private final SavingsPooledInterestPostingService postingService;
|
private final SavingsPooledInterestPostingService postingService;
|
||||||
|
private final SavingsInterestPostingReportService reportService;
|
||||||
|
private final PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accrue for the day, then post for any product whose posting date has arrived.
|
* The nightly run: settles any approved interest report still unposted, then accrues the day.
|
||||||
*
|
*
|
||||||
* Deliberately not {@code @Transactional}. The accrual writes every account's ledger row and the
|
* <p>
|
||||||
* posting then reads and updates those same rows in a transaction of its own
|
* This run no longer decides to pay anybody. Interest is paid only from an approved posting
|
||||||
* ({@code postForProduct} is REQUIRES_NEW, so that one product's failure cannot roll back
|
* report, normally when the last branch closes the day. What is left here is the safety net for a
|
||||||
* another's payment). Holding an outer transaction across both would mean this thread still owns
|
* report that was approved but could not be posted then, and it runs BEFORE the accrual on
|
||||||
* the row locks the accrual took while the posting's separate transaction waits for them — each
|
* purpose: by the time this job runs the business date has already moved past the posting date,
|
||||||
* waiting on the other, and the job hanging silently with nothing in the log. So each step
|
* and the accrual rolls each account's ledger row into the next cycle, which would leave nothing
|
||||||
* commits on its own before the next begins.
|
* for the posting to settle.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Deliberately not {@code @Transactional}. Each report posts in a transaction of its own, and the
|
||||||
|
* accrual then commits on its own, so neither holds the other's row locks.
|
||||||
*/
|
*/
|
||||||
public void runForDate(final LocalDate businessDate) {
|
public void runForDate(final LocalDate businessDate) {
|
||||||
clearStaleRunningFlag();
|
clearStaleRunningFlag();
|
||||||
|
final List<String> failures = postApprovedReports(businessDate);
|
||||||
accrualService.accrueForDate(businessDate);
|
accrualService.accrueForDate(businessDate);
|
||||||
enrolNewProducts();
|
enrolNewProducts();
|
||||||
|
|
||||||
final List<Map<String, Object>> schedules = jdbcTemplate.queryForList("""
|
// Keeps each product's next posting date on the timetable in step with the ledger, which
|
||||||
SELECT s.savings_product_id, s.posting_period_enum
|
// the posting loop used to do as a side effect of looking for work.
|
||||||
FROM m_savings_interest_posting_schedule s
|
for (final Long productId : jdbcTemplate.queryForList(
|
||||||
JOIN m_savings_product p ON p.id = s.savings_product_id
|
"SELECT savings_product_id FROM m_savings_interest_posting_schedule WHERE is_active = true", Long.class)) {
|
||||||
WHERE s.is_active = true AND p.deposit_type_enum = 100
|
updateSchedule(productId, businessDate, null);
|
||||||
ORDER BY s.savings_product_id
|
|
||||||
""");
|
|
||||||
|
|
||||||
// Products that could not be paid, collected so the run can fail loudly at the end.
|
|
||||||
//
|
|
||||||
// Carrying on past one product is right — a mapping missing on one product must not stop
|
|
||||||
// every member of every other from being paid. Reporting the job as successful afterwards is
|
|
||||||
// not: the scheduler recorded 'success', the operator saw a green run, and the only trace
|
|
||||||
// that nobody had been paid was a log line and a row in m_savings_interest_posting_run that
|
|
||||||
// nothing reads. On a night when every product fails, that is a whole day of interest
|
|
||||||
// silently unpaid.
|
|
||||||
final List<String> failures = new java.util.ArrayList<>();
|
|
||||||
|
|
||||||
for (final Map<String, Object> schedule : schedules) {
|
|
||||||
final long productId = ((Number) schedule.get("savings_product_id")).longValue();
|
|
||||||
|
|
||||||
// Whether anything is due is decided inside postForProduct, not here.
|
|
||||||
//
|
|
||||||
// This used to pre-check with its own COUNT over m_savings_daily_interest, and that
|
|
||||||
// deadlocked the run: accrueForDate above is @Transactional and has just written every
|
|
||||||
// ledger row, so its transaction still holds them; the COUNT then read those rows on
|
|
||||||
// this thread's transaction, and postForProduct — which runs REQUIRES_NEW, in a
|
|
||||||
// transaction of its own — sat waiting for a lock this thread was holding and would only
|
|
||||||
// release after postForProduct returned. Each waited on the other and the job hung
|
|
||||||
// silently, with nothing in the log and the flag left saying it was still running.
|
|
||||||
//
|
|
||||||
// postForProduct already selects what is due and returns zero when there is nothing, so
|
|
||||||
// the pre-check bought nothing but the deadlock. Its selection is by posting_date rather
|
|
||||||
// than by the run date, which is what makes a missed posting date catch up on a later
|
|
||||||
// run instead of being skipped for good.
|
|
||||||
try {
|
|
||||||
final BigDecimal paid = postingService.postForProduct(productId, businessDate);
|
|
||||||
if (paid.signum() == 0) {
|
|
||||||
updateSchedule(productId, businessDate, null);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
updateSchedule(productId, businessDate, "COMPLETED");
|
|
||||||
log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate);
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
// The product rolled back on its own; record why and let the others run.
|
|
||||||
log.error("Savings interest: product {} failed to post on {}", productId, businessDate, e);
|
|
||||||
// An UPDATE, so it writes nothing when the product threw before openRun() inserted
|
|
||||||
// the row — which is exactly what a product with no GL mapping does. The failure is
|
|
||||||
// therefore collected in memory as well, so it cannot be lost with the breadcrumb.
|
|
||||||
jdbcTemplate.update("""
|
|
||||||
UPDATE m_savings_interest_posting_run
|
|
||||||
SET status = 'FAILED', failure_reason = ?
|
|
||||||
WHERE savings_product_id = ? AND posting_date = ?
|
|
||||||
""", truncate(e.getMessage()), productId, businessDate);
|
|
||||||
updateSchedule(productId, businessDate, "FAILED");
|
|
||||||
failures.add("product " + productId + ": " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fails the job so the scheduler records it as failed and an operator is told. Every product
|
// Fails the job so the scheduler records it as failed and an operator is told. Every report
|
||||||
// that could be paid has already been paid and committed by this point — this reports what
|
// that could be paid has already been paid and committed by this point.
|
||||||
// happened, it does not undo it.
|
|
||||||
if (!failures.isEmpty()) {
|
if (!failures.isEmpty()) {
|
||||||
throw new IllegalStateException("Savings interest could not be posted on " + businessDate + " for "
|
throw new IllegalStateException("Savings interest could not be posted on " + businessDate + " for "
|
||||||
+ failures.size() + " product(s): " + String.join("; ", failures));
|
+ failures.size() + " report(s): " + String.join("; ", failures));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts every approved interest posting report dated on or before the date.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Each report posts in a new transaction of its own. That is required, not a preference: this is
|
||||||
|
* called from the day close's after-commit callback, where the closing transaction has finished
|
||||||
|
* but is still bound to the thread, and joining it would write nothing that ever commits. It also
|
||||||
|
* keeps one product's failure from rolling back another's payment. A report that fails stays
|
||||||
|
* APPROVED with the reason recorded, and the nightly run tries it again.
|
||||||
|
*
|
||||||
|
* @return one message per report that could not be posted
|
||||||
|
*/
|
||||||
|
public List<String> postApprovedReports(final LocalDate upToDate) {
|
||||||
|
return postReports(reportService.approvedReportIds(upToDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts the given approved reports, each in a new transaction of its own (see
|
||||||
|
* {@link #postApprovedReports}). Used by the day close for one branch's reports.
|
||||||
|
*
|
||||||
|
* @return one message per report that could not be posted
|
||||||
|
*/
|
||||||
|
public List<String> postReports(final List<Long> reportIds) {
|
||||||
|
final TransactionTemplate newTransaction = new TransactionTemplate(transactionManager);
|
||||||
|
newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||||
|
|
||||||
|
final List<String> failures = new java.util.ArrayList<>();
|
||||||
|
for (final Long reportId : reportIds) {
|
||||||
|
final Map<String, Object> report = jdbcTemplate.queryForMap(
|
||||||
|
"SELECT savings_product_id, posting_date FROM m_savings_interest_posting_report WHERE id = ?", reportId);
|
||||||
|
final long productId = ((Number) report.get("savings_product_id")).longValue();
|
||||||
|
final LocalDate postingDate = ((java.sql.Date) report.get("posting_date")).toLocalDate();
|
||||||
|
try {
|
||||||
|
final BigDecimal paid = newTransaction.execute(status -> postingService.postReport(reportId));
|
||||||
|
newTransaction.executeWithoutResult(status -> updateSchedule(productId, postingDate, "COMPLETED"));
|
||||||
|
log.info("Savings interest: report {} for product {} posted {} on {}", reportId, productId, paid, postingDate);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Savings interest: report {} for product {} failed to post on {}", reportId, productId, postingDate, e);
|
||||||
|
newTransaction.executeWithoutResult(status -> {
|
||||||
|
reportService.recordPostingFailure(reportId, e.getMessage());
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_interest_posting_run
|
||||||
|
SET status = 'FAILED', failure_reason = ?
|
||||||
|
WHERE report_id = ?
|
||||||
|
""", truncate(e.getMessage()), reportId);
|
||||||
|
updateSchedule(productId, postingDate, "FAILED");
|
||||||
|
});
|
||||||
|
failures.add("report " + reportId + " (product " + productId + "): " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Releases this job's own currently_running flag if an earlier run left it set.
|
* Releases this job's own currently_running flag if an earlier run left it set.
|
||||||
*
|
*
|
||||||
|
|||||||
+24
-4
@@ -243,7 +243,7 @@ public class SavingsDailyInterestService {
|
|||||||
carriedInterest = backfill.cumulativeInterest();
|
carriedInterest = backfill.cumulativeInterest();
|
||||||
} else {
|
} else {
|
||||||
final Map<String, Object> prev = previous.get(0);
|
final Map<String, Object> prev = previous.get(0);
|
||||||
openingBalance = (BigDecimal) prev.get("closing_balance");
|
final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate();
|
||||||
final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day"));
|
final boolean prevWasPosting = Boolean.TRUE.equals(prev.get("is_posting_day"));
|
||||||
final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate();
|
final LocalDate prevCycleEnd = ((java.sql.Date) prev.get("cycle_end_date")).toLocalDate();
|
||||||
// A cycle is finished either because it was paid out or because its end date has gone
|
// A cycle is finished either because it was paid out or because its end date has gone
|
||||||
@@ -251,11 +251,24 @@ public class SavingsDailyInterestService {
|
|||||||
// run was missed, or the account was accrued again after a break - and treating it as
|
// run was missed, or the account was accrued again after a break - and treating it as
|
||||||
// unfinished would leave the row in a cycle that ended before the day it records.
|
// unfinished would leave the row in a cycle that ended before the day it records.
|
||||||
if (prevWasPosting || businessDate.isAfter(prevCycleEnd)) {
|
if (prevWasPosting || businessDate.isAfter(prevCycleEnd)) {
|
||||||
carriedInterest = BigDecimal.ZERO;
|
|
||||||
cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1;
|
cycleNumber = ((Number) prev.get("cycle_number")).intValue() + 1;
|
||||||
cycleStart = prevCycleEnd.plusDays(1);
|
cycleStart = prevCycleEnd.plusDays(1);
|
||||||
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
|
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
|
||||||
financialYearBeginningMonth);
|
financialYearBeginningMonth);
|
||||||
|
// The new cycle may already be days old: the business date moved on by more than a
|
||||||
|
// day, or nights were missed. Those days earned too. Starting the cycle at zero and
|
||||||
|
// counting only today dropped them — a jump from 30 Sep to 31 Oct paid one day of
|
||||||
|
// October. They are replayed from the account's transactions, as a first row is, and
|
||||||
|
// the opening balance comes from the same replay so money moved in the gap counts.
|
||||||
|
if (cycleStart.isBefore(businessDate)) {
|
||||||
|
final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
|
||||||
|
minBalance, averageDailyBalanceOf(calcType));
|
||||||
|
openingBalance = gap.closingBalance();
|
||||||
|
carriedInterest = gap.cumulativeInterest();
|
||||||
|
} else {
|
||||||
|
openingBalance = (BigDecimal) prev.get("closing_balance");
|
||||||
|
carriedInterest = BigDecimal.ZERO;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
cycleNumber = ((Number) prev.get("cycle_number")).intValue();
|
cycleNumber = ((Number) prev.get("cycle_number")).intValue();
|
||||||
cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate();
|
cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate();
|
||||||
@@ -274,12 +287,15 @@ public class SavingsDailyInterestService {
|
|||||||
// So the gap is replayed from the account's own transactions, the same way a first
|
// So the gap is replayed from the account's own transactions, the same way a first
|
||||||
// row is built, and the result stands in for the running total the ledger would be
|
// row is built, and the result stands in for the running total the ledger would be
|
||||||
// holding had it run every night.
|
// holding had it run every night.
|
||||||
final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate();
|
|
||||||
if (prevBalanceDate.plusDays(1).isBefore(businessDate)) {
|
if (prevBalanceDate.plusDays(1).isBefore(businessDate)) {
|
||||||
final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
|
final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
|
||||||
minBalance, averageDailyBalanceOf(calcType));
|
minBalance, averageDailyBalanceOf(calcType));
|
||||||
|
// The replay's balance, not the stale row's: deposits made during the gap were
|
||||||
|
// otherwise left out of every balance from here on.
|
||||||
|
openingBalance = gap.closingBalance();
|
||||||
carriedInterest = gap.cumulativeInterest();
|
carriedInterest = gap.cumulativeInterest();
|
||||||
} else {
|
} else {
|
||||||
|
openingBalance = (BigDecimal) prev.get("closing_balance");
|
||||||
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
|
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -506,7 +522,11 @@ public class SavingsDailyInterestService {
|
|||||||
final BigDecimal rate, final int daysInYear, final BigDecimal minBalance, final boolean averageDailyBalance) {
|
final BigDecimal rate, final int daysInYear, final BigDecimal minBalance, final boolean averageDailyBalance) {
|
||||||
|
|
||||||
if (!cycleStart.isBefore(upTo)) {
|
if (!cycleStart.isBefore(upTo)) {
|
||||||
return new Backfill(BigDecimal.ZERO, BigDecimal.ZERO);
|
// No day of the cycle to replay — it opens today — but the money held going into it is
|
||||||
|
// still there. Returning a zero balance made every re-accrual on a cycle's first day (a
|
||||||
|
// deposit, or the noon report, on the 1st) open the account at nil: an account holding
|
||||||
|
// 11,503.15 then earned only on what was deposited that day.
|
||||||
|
return new Backfill(balanceAsOf(accountId, upTo.minusDays(1)), BigDecimal.ZERO);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything on or before the eve of the ledger's first day, keyed by day. The balance before
|
// Everything on or before the eve of the ledger's first day, keyed by day. The balance before
|
||||||
|
|||||||
+578
@@ -0,0 +1,578 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.TransactionDefinition;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The interest posting report: what each savings account of one branch will be paid on its product's
|
||||||
|
* posting date, put in front of that branch before any money moves.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* One report per branch and product. On a posting date the noon job generates one for every branch
|
||||||
|
* holding accounts with interest due; a user of that branch may generate or regenerate it at any time
|
||||||
|
* — the previous report is superseded, not overwritten — and approves it. The branch's approved
|
||||||
|
* reports are posted when that branch closes its day, and until they are approved it cannot close.
|
||||||
|
* The head office closes last, once every branch open that day has closed.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Only the branch acts on its own reports. The single exception is a branch that did not open that
|
||||||
|
* day: the head office may then generate and approve its report on its behalf, and posts it at the
|
||||||
|
* head office's own close. Every entry is still raised in the branch the customers belong to.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The figures are the daily ledger's own: each account's running interest for the cycle, rounded to
|
||||||
|
* two decimals, with the paise the rounding loses given to the largest balance so the report's total
|
||||||
|
* is the branch's pooled interest exactly.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class SavingsInterestPostingReportService {
|
||||||
|
|
||||||
|
public static final String GENERATED = "GENERATED";
|
||||||
|
public static final String APPROVED = "APPROVED";
|
||||||
|
public static final String POSTED = "POSTED";
|
||||||
|
public static final String SUPERSEDED = "SUPERSEDED";
|
||||||
|
|
||||||
|
private static final int MONEY_SCALE = 2;
|
||||||
|
private static final RoundingMode MONEY_ROUNDING = RoundingMode.HALF_EVEN;
|
||||||
|
/** m_savings_account.status_enum for an active account. */
|
||||||
|
private static final int ACTIVE = 300;
|
||||||
|
|
||||||
|
/** A product with interest due in one branch. */
|
||||||
|
private record DueKey(Long productId, Long officeId) {}
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final SavingsDailyInterestService accrualService;
|
||||||
|
private final SavingsPooledInterestPostingService postingService;
|
||||||
|
private final TransactionTemplate transactionTemplate;
|
||||||
|
|
||||||
|
public SavingsInterestPostingReportService(final JdbcTemplate jdbcTemplate, final SavingsDailyInterestService accrualService,
|
||||||
|
final SavingsPooledInterestPostingService postingService, final PlatformTransactionManager transactionManager) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.accrualService = accrualService;
|
||||||
|
this.postingService = postingService;
|
||||||
|
// Always a transaction of its own. The noon job runs inside the batch step's transaction, and
|
||||||
|
// generating in it would read the accrual through that uncommitted transaction and hold its
|
||||||
|
// row locks for the whole run; a report committed on its own is also visible to the screen at once.
|
||||||
|
this.transactionTemplate = new TransactionTemplate(transactionManager);
|
||||||
|
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── generation ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The noon job: brings the ledger up to the business date, then generates a report for every
|
||||||
|
* branch and product with interest due that does not already have one.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Each report is generated in its own transaction, so one that cannot be — a GL mapping missing,
|
||||||
|
* say — does not stop the others. The failures are raised together at the end so the job is
|
||||||
|
* recorded as failed.
|
||||||
|
*
|
||||||
|
* @return the ids of the reports generated
|
||||||
|
*/
|
||||||
|
public List<Long> generateDue(final LocalDate postingDate) {
|
||||||
|
// So each account's figure includes the posting date itself, on the balance it holds now.
|
||||||
|
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(postingDate));
|
||||||
|
|
||||||
|
final List<Long> generated = new ArrayList<>();
|
||||||
|
final List<String> failures = new ArrayList<>();
|
||||||
|
for (final DueKey due : dueKeys(postingDate, null)) {
|
||||||
|
if (activeReportId(due.productId(), due.officeId(), postingDate) != null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
generated.add(transactionTemplate.execute(status -> generate(due.productId(), due.officeId(), postingDate, "AUTO", null)));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
log.error("Savings interest report: product {} branch {} could not be reported for {}", due.productId(),
|
||||||
|
due.officeId(), postingDate, e);
|
||||||
|
failures.add("product " + due.productId() + " branch " + due.officeId() + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!failures.isEmpty()) {
|
||||||
|
throw new IllegalStateException("Savings interest posting report could not be generated on " + postingDate + " for "
|
||||||
|
+ failures.size() + " report(s): " + String.join("; ", failures));
|
||||||
|
}
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the report afresh for one branch and product, on the user's request.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Allowed at any time of day; only the noon job waits for noon. Not for a date after the business
|
||||||
|
* date, whose interest has not been earned yet. A posted report cannot be regenerated: its money
|
||||||
|
* has moved. An approved one can, and has to be approved again.
|
||||||
|
*/
|
||||||
|
public Long regenerate(final Long productId, final Long officeId, final LocalDate postingDate, final AppUser user) {
|
||||||
|
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||||
|
if (postingDate.isAfter(businessDate)) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.future.date",
|
||||||
|
"The report for " + postingDate + " cannot be generated before that date.");
|
||||||
|
}
|
||||||
|
authorize(user, officeId, businessDate);
|
||||||
|
transactionTemplate.executeWithoutResult(status -> accrualService.accrueForDate(businessDate));
|
||||||
|
return transactionTemplate.execute(status -> generate(productId, officeId, postingDate, "MANUAL", user.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approves a generated report. A user of the report's branch approves it; the head office may
|
||||||
|
* approve it only for a branch that did not open that day, and the report records that it did.
|
||||||
|
*/
|
||||||
|
public void approve(final Long reportId, final AppUser user) {
|
||||||
|
final List<Map<String, Object>> found = jdbcTemplate
|
||||||
|
.queryForList("SELECT office_id FROM m_savings_interest_posting_report WHERE id = ?", reportId);
|
||||||
|
if (found.isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found",
|
||||||
|
"Interest posting report " + reportId + " does not exist.");
|
||||||
|
}
|
||||||
|
final Long officeId = ((Number) found.get(0).get("office_id")).longValue();
|
||||||
|
final boolean onBehalf = authorize(user, officeId, DateUtils.getBusinessLocalDate());
|
||||||
|
final int approved = jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_interest_posting_report
|
||||||
|
SET status = 'APPROVED', approved_by = ?, approved_at = CURRENT_TIMESTAMP, approved_on_behalf = ?
|
||||||
|
WHERE id = ? AND status = 'GENERATED'
|
||||||
|
""", user.getId(), onBehalf, reportId);
|
||||||
|
if (approved == 0) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.generated",
|
||||||
|
"Only a report in GENERATED status can be approved. Reload the report — it may have been approved, "
|
||||||
|
+ "regenerated or posted already.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── day close ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What stops one branch from closing the day: a product with interest due in the branch and no
|
||||||
|
* approved (or posted) report for the date, and any older report of the branch still awaiting
|
||||||
|
* approval — one left from a day the branch did not open. A report that was never generated
|
||||||
|
* blocks the close too.
|
||||||
|
*/
|
||||||
|
public List<String> reportsBlockingDayClose(final Long officeId, final LocalDate businessDate) {
|
||||||
|
final Set<String> blocking = new LinkedHashSet<>();
|
||||||
|
for (final DueKey due : dueKeys(businessDate, officeId)) {
|
||||||
|
final Long settled = jdbcTemplate.queryForObject("""
|
||||||
|
SELECT COUNT(*) FROM m_savings_interest_posting_report
|
||||||
|
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('APPROVED', 'POSTED')
|
||||||
|
""", Long.class, due.productId(), officeId, businessDate);
|
||||||
|
if (settled == null || settled == 0) {
|
||||||
|
blocking.add(productName(due.productId()) + " (" + businessDate + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final Map<String, Object> pending : jdbcTemplate.queryForList("""
|
||||||
|
SELECT p.name, r.posting_date FROM m_savings_interest_posting_report r
|
||||||
|
JOIN m_savings_product p ON p.id = r.savings_product_id
|
||||||
|
WHERE r.office_id = ? AND r.status = 'GENERATED' AND r.posting_date <= ?
|
||||||
|
ORDER BY r.posting_date
|
||||||
|
""", officeId, businessDate)) {
|
||||||
|
blocking.add(pending.get("name") + " (" + pending.get("posting_date") + ")");
|
||||||
|
}
|
||||||
|
return new ArrayList<>(blocking);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The branch's approved reports, posted when it closes the day. */
|
||||||
|
public List<Long> approvedReportIds(final Long officeId, final LocalDate upToDate) {
|
||||||
|
return jdbcTemplate.queryForList("""
|
||||||
|
SELECT id FROM m_savings_interest_posting_report
|
||||||
|
WHERE status = 'APPROVED' AND office_id = ? AND posting_date <= ?
|
||||||
|
ORDER BY posting_date, savings_product_id
|
||||||
|
""", Long.class, officeId, upToDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approved reports of branches that did not open on the date — what the head office posts at its
|
||||||
|
* own close, the branches having no close of their own that day.
|
||||||
|
*/
|
||||||
|
public List<Long> approvedReportIdsOfBranchesNotOpened(final LocalDate businessDate, final Long headOfficeId) {
|
||||||
|
return jdbcTemplate.queryForList("""
|
||||||
|
SELECT r.id FROM m_savings_interest_posting_report r
|
||||||
|
WHERE r.status = 'APPROVED' AND r.posting_date <= ? AND r.office_id <> ?
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM m_branch_day_register b WHERE b.office_id = r.office_id AND b.business_date = ?)
|
||||||
|
ORDER BY r.posting_date, r.office_id, r.savings_product_id
|
||||||
|
""", Long.class, businessDate, headOfficeId, businessDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every approved report still waiting, for the nightly safety net. */
|
||||||
|
public List<Long> approvedReportIds(final LocalDate upToDate) {
|
||||||
|
return jdbcTemplate.queryForList("""
|
||||||
|
SELECT id FROM m_savings_interest_posting_report
|
||||||
|
WHERE status = 'APPROVED' AND posting_date <= ?
|
||||||
|
ORDER BY posting_date, office_id, savings_product_id
|
||||||
|
""", Long.class, upToDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records why an approved report could not be posted; it stays APPROVED for the next attempt. */
|
||||||
|
public void recordPostingFailure(final Long reportId, final String reason) {
|
||||||
|
jdbcTemplate.update("UPDATE m_savings_interest_posting_report SET failure_reason = ? WHERE id = ?",
|
||||||
|
reason == null ? "unknown failure" : reason.length() > 1000 ? reason.substring(0, 1000) : reason, reportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── reading ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports for the screen. A branch user sees only their branch; a head office user sees every
|
||||||
|
* branch, so it can act for one that did not open. canAct says whether this user may regenerate
|
||||||
|
* or approve the report.
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> list(final LocalDate postingDate, final String status, final Long productId, final Long officeId,
|
||||||
|
final AppUser user) {
|
||||||
|
final Long userOffice = officeOf(user);
|
||||||
|
final boolean headOffice = isHeadOffice(userOffice);
|
||||||
|
final Long branch = headOffice ? officeId : userOffice;
|
||||||
|
|
||||||
|
final StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
|
||||||
|
o.name AS "officeName", r.posting_date AS "postingDate", r.status, r.total_interest AS "totalInterest",
|
||||||
|
r.account_count AS "accountCount", r.generation_mode AS "generationMode", r.generated_at AS "generatedAt",
|
||||||
|
gu.username AS "generatedBy", r.approved_at AS "approvedAt", au.username AS "approvedBy",
|
||||||
|
r.approved_on_behalf AS "approvedOnBehalf", r.posted_at AS "postedAt", r.failure_reason AS "failureReason"
|
||||||
|
FROM m_savings_interest_posting_report r
|
||||||
|
JOIN m_savings_product p ON p.id = r.savings_product_id
|
||||||
|
LEFT JOIN m_office o ON o.id = r.office_id
|
||||||
|
LEFT JOIN m_appuser gu ON gu.id = r.generated_by
|
||||||
|
LEFT JOIN m_appuser au ON au.id = r.approved_by
|
||||||
|
WHERE 1 = 1
|
||||||
|
""");
|
||||||
|
final List<Object> params = new ArrayList<>();
|
||||||
|
if (postingDate != null) {
|
||||||
|
sql.append(" AND r.posting_date = ?");
|
||||||
|
params.add(postingDate);
|
||||||
|
}
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
sql.append(" AND r.status = ?");
|
||||||
|
params.add(status.trim().toUpperCase());
|
||||||
|
} else {
|
||||||
|
sql.append(" AND r.status <> 'SUPERSEDED'");
|
||||||
|
}
|
||||||
|
if (productId != null) {
|
||||||
|
sql.append(" AND r.savings_product_id = ?");
|
||||||
|
params.add(productId);
|
||||||
|
}
|
||||||
|
if (branch != null) {
|
||||||
|
sql.append(" AND r.office_id = ?");
|
||||||
|
params.add(branch);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY r.posting_date DESC, o.name, p.name, r.id DESC");
|
||||||
|
|
||||||
|
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||||
|
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>(row);
|
||||||
|
final Long reportOffice = row.get("officeId") == null ? null : ((Number) row.get("officeId")).longValue();
|
||||||
|
out.put("canAct", reportOffice != null && mayAct(userOffice, reportOffice, businessDate));
|
||||||
|
rows.add(out);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> detail(final Long reportId) {
|
||||||
|
final List<Map<String, Object>> found = jdbcTemplate.queryForList("""
|
||||||
|
SELECT r.id, r.savings_product_id AS "productId", p.name AS "productName", r.office_id AS "officeId",
|
||||||
|
o.name AS "officeName", r.posting_date AS "postingDate",
|
||||||
|
r.status, r.total_interest AS "totalInterest", r.account_count AS "accountCount",
|
||||||
|
r.generation_mode AS "generationMode", r.generated_at AS "generatedAt", gu.username AS "generatedBy",
|
||||||
|
r.approved_at AS "approvedAt", au.username AS "approvedBy", r.approved_on_behalf AS "approvedOnBehalf",
|
||||||
|
r.posted_at AS "postedAt", r.failure_reason AS "failureReason",
|
||||||
|
pg.gl_code AS "payableGlCode", pg.name AS "payableGlName",
|
||||||
|
dg.gl_code AS "paidGlCode", dg.name AS "paidGlName",
|
||||||
|
eg.gl_code AS "expenseGlCode", eg.name AS "expenseGlName"
|
||||||
|
FROM m_savings_interest_posting_report r
|
||||||
|
JOIN m_savings_product p ON p.id = r.savings_product_id
|
||||||
|
LEFT JOIN m_office o ON o.id = r.office_id
|
||||||
|
LEFT JOIN m_appuser gu ON gu.id = r.generated_by
|
||||||
|
LEFT JOIN m_appuser au ON au.id = r.approved_by
|
||||||
|
LEFT JOIN acc_gl_account pg ON pg.id = r.payable_gl_account_id
|
||||||
|
LEFT JOIN acc_gl_account dg ON dg.id = r.paid_gl_account_id
|
||||||
|
LEFT JOIN acc_product_mapping em ON em.product_id = r.savings_product_id AND em.product_type = 2
|
||||||
|
AND em.financial_account_type = 3 AND em.payment_type IS NULL
|
||||||
|
LEFT JOIN acc_gl_account eg ON eg.id = em.gl_account_id
|
||||||
|
WHERE r.id = ?
|
||||||
|
""", reportId);
|
||||||
|
if (found.isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.not.found",
|
||||||
|
"Interest posting report " + reportId + " does not exist.");
|
||||||
|
}
|
||||||
|
final Map<String, Object> report = new LinkedHashMap<>(found.get(0));
|
||||||
|
report.put("lines", jdbcTemplate.queryForList("""
|
||||||
|
SELECT l.savings_account_id AS "savingsAccountId", l.account_no AS "accountNo", l.client_name AS "clientName",
|
||||||
|
l.interest_amount AS "interestAmount", l.balance_before AS "balanceBefore",
|
||||||
|
l.balance_after AS "balanceAfter", l.savings_transaction_id AS "savingsTransactionId"
|
||||||
|
FROM m_savings_interest_posting_report_line l
|
||||||
|
WHERE l.report_id = ?
|
||||||
|
ORDER BY l.account_no
|
||||||
|
""", reportId));
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch and product interest for the head office: per report, how many customers and how much
|
||||||
|
* interest, and where it stands. Superseded reports are left out — each line is the report that
|
||||||
|
* counts for that branch, product and date.
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> summary(final LocalDate fromDate, final LocalDate toDate, final Long productId) {
|
||||||
|
final StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT r.id, r.posting_date AS "postingDate", r.office_id AS "officeId", o.name AS "officeName",
|
||||||
|
r.savings_product_id AS "productId", p.name AS "productName", r.account_count AS "accountCount",
|
||||||
|
r.total_interest AS "totalInterest", r.status, au.username AS "approvedBy",
|
||||||
|
r.approved_on_behalf AS "approvedOnBehalf", r.approved_at AS "approvedAt", r.posted_at AS "postedAt"
|
||||||
|
FROM m_savings_interest_posting_report r
|
||||||
|
JOIN m_savings_product p ON p.id = r.savings_product_id
|
||||||
|
LEFT JOIN m_office o ON o.id = r.office_id
|
||||||
|
LEFT JOIN m_appuser au ON au.id = r.approved_by
|
||||||
|
WHERE r.status <> 'SUPERSEDED'
|
||||||
|
""");
|
||||||
|
final List<Object> params = new ArrayList<>();
|
||||||
|
if (fromDate != null) {
|
||||||
|
sql.append(" AND r.posting_date >= ?");
|
||||||
|
params.add(fromDate);
|
||||||
|
}
|
||||||
|
if (toDate != null) {
|
||||||
|
sql.append(" AND r.posting_date <= ?");
|
||||||
|
params.add(toDate);
|
||||||
|
}
|
||||||
|
if (productId != null) {
|
||||||
|
sql.append(" AND r.savings_product_id = ?");
|
||||||
|
params.add(productId);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY r.posting_date DESC, p.name, o.name");
|
||||||
|
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch and product pairs with interest due by the date that have no report yet — the noon job ran
|
||||||
|
* before they had anything due, or failed. Limited to what the user may act on.
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> dueWithoutReport(final LocalDate postingDate, final AppUser user) {
|
||||||
|
final List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
// A future date has nothing due yet, and offering to generate for it would label this cycle's
|
||||||
|
// part-earned interest with a date it has not reached.
|
||||||
|
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||||
|
if (postingDate.isAfter(businessDate)) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
final Long userOffice = officeOf(user);
|
||||||
|
for (final DueKey due : dueKeys(postingDate, isHeadOffice(userOffice) ? null : userOffice)) {
|
||||||
|
if (activeReportId(due.productId(), due.officeId(), postingDate) == null
|
||||||
|
&& mayAct(userOffice, due.officeId(), businessDate)) {
|
||||||
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("productId", due.productId());
|
||||||
|
row.put("productName", productName(due.productId()));
|
||||||
|
row.put("officeId", due.officeId());
|
||||||
|
row.put("officeName", jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, due.officeId()));
|
||||||
|
row.put("postingDate", postingDate.toString());
|
||||||
|
out.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── who may act ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public boolean isHeadOffice(final Long officeId) {
|
||||||
|
final List<Long> parent = jdbcTemplate.queryForList("SELECT parent_id FROM m_office WHERE id = ?", Long.class, officeId);
|
||||||
|
return !parent.isEmpty() && parent.get(0) == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the branch opened its day on the date — the head office may act only for one that did not. */
|
||||||
|
public boolean branchOpenedOn(final Long officeId, final LocalDate businessDate) {
|
||||||
|
final Long rows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
|
||||||
|
Long.class, officeId, businessDate);
|
||||||
|
return rows != null && rows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean mayAct(final Long userOffice, final Long reportOffice, final LocalDate businessDate) {
|
||||||
|
if (userOffice == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (userOffice.equals(reportOffice)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isHeadOffice(userOffice) && !branchOpenedOn(reportOffice, businessDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return true where the head office is acting on a branch's behalf */
|
||||||
|
private boolean authorize(final AppUser user, final Long reportOffice, final LocalDate businessDate) {
|
||||||
|
final Long userOffice = officeOf(user);
|
||||||
|
if (userOffice != null && userOffice.equals(reportOffice)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (mayAct(userOffice, reportOffice, businessDate)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final String branch = jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class, reportOffice);
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.other.branch",
|
||||||
|
"Only " + branch + " can generate or approve its own interest posting report. The head office may do so only on a day "
|
||||||
|
+ branch + " has not opened.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long officeOf(final AppUser user) {
|
||||||
|
return user == null || user.getOffice() == null ? null : user.getOffice().getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internals ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch and product pairs with interest due on or before the date: a savings product on the
|
||||||
|
* daily-interest timetable whose ledger holds unpaid interest for a cycle that ends by then, in
|
||||||
|
* accounts belonging to that branch.
|
||||||
|
*/
|
||||||
|
private List<DueKey> dueKeys(final LocalDate postingDate, final Long officeId) {
|
||||||
|
final StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT DISTINCT d.savings_product_id, COALESCE(c.office_id, g.office_id) AS office_id
|
||||||
|
FROM m_savings_daily_interest d
|
||||||
|
JOIN m_savings_product p ON p.id = d.savings_product_id
|
||||||
|
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||||
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||||
|
JOIN m_savings_interest_posting_schedule s ON s.savings_product_id = p.id AND s.is_active = true
|
||||||
|
WHERE p.deposit_type_enum = 100
|
||||||
|
AND sa.status_enum = ?
|
||||||
|
AND d.posting_date <= ?
|
||||||
|
AND d.savings_transaction_id IS NULL
|
||||||
|
AND d.cycle_cumulative_interest > 0
|
||||||
|
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
|
||||||
|
""");
|
||||||
|
final List<Object> params = new ArrayList<>(List.of(ACTIVE, postingDate));
|
||||||
|
if (officeId != null) {
|
||||||
|
sql.append(" AND COALESCE(c.office_id, g.office_id) = ?");
|
||||||
|
params.add(officeId);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY 2, 1");
|
||||||
|
return jdbcTemplate.queryForList(sql.toString(), params.toArray()).stream()
|
||||||
|
.map(r -> new DueKey(((Number) r.get("savings_product_id")).longValue(), ((Number) r.get("office_id")).longValue()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long activeReportId(final Long productId, final Long officeId, final LocalDate postingDate) {
|
||||||
|
final List<Long> ids = jdbcTemplate.queryForList("""
|
||||||
|
SELECT id FROM m_savings_interest_posting_report
|
||||||
|
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED'
|
||||||
|
""", Long.class, productId, officeId, postingDate);
|
||||||
|
return ids.isEmpty() ? null : ids.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String productName(final Long productId) {
|
||||||
|
return jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class, productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs inside the caller's transaction. */
|
||||||
|
private Long generate(final Long productId, final Long officeId, final LocalDate postingDate, final String mode, final Long userId) {
|
||||||
|
final List<Map<String, Object>> previous = jdbcTemplate.queryForList("""
|
||||||
|
SELECT id, status FROM m_savings_interest_posting_report
|
||||||
|
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status <> 'SUPERSEDED'
|
||||||
|
FOR UPDATE
|
||||||
|
""", productId, officeId, postingDate);
|
||||||
|
for (final Map<String, Object> report : previous) {
|
||||||
|
if (POSTED.equals(report.get("status"))) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.already.posted",
|
||||||
|
"Interest for this product and branch on " + postingDate + " has already been posted; the report cannot be"
|
||||||
|
+ " regenerated.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_interest_posting_report SET status = 'SUPERSEDED'
|
||||||
|
WHERE savings_product_id = ? AND office_id = ? AND posting_date = ? AND status IN ('GENERATED', 'APPROVED')
|
||||||
|
""", productId, officeId, postingDate);
|
||||||
|
|
||||||
|
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
|
||||||
|
SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance, d.cycle_cumulative_interest AS gross_interest,
|
||||||
|
sa.account_no, COALESCE(c.display_name, g.display_name) AS client_name
|
||||||
|
FROM m_savings_daily_interest d
|
||||||
|
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||||
|
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||||
|
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||||
|
WHERE d.savings_product_id = ? AND d.posting_date <= ?
|
||||||
|
AND d.savings_transaction_id IS NULL
|
||||||
|
AND d.cycle_cumulative_interest > 0
|
||||||
|
AND sa.status_enum = ?
|
||||||
|
AND COALESCE(c.office_id, g.office_id) = ?
|
||||||
|
ORDER BY d.savings_account_id
|
||||||
|
""", productId, postingDate, ACTIVE, officeId);
|
||||||
|
if (due.isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.nothing.due",
|
||||||
|
"No interest is due for this product in this branch on " + postingDate + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The GL accounts as the product maps them now; a product missing one is refused here, before
|
||||||
|
// anybody approves figures that could never be posted.
|
||||||
|
final SavingsPooledInterestPostingService.PostingGlAccounts gls = postingService.resolvePostingGlAccounts(productId);
|
||||||
|
|
||||||
|
// Round each customer's share, then make the rounded shares add up to the rounded pool: the
|
||||||
|
// residual goes to the largest balance, where a paisa is proportionally smallest.
|
||||||
|
BigDecimal grossTotal = BigDecimal.ZERO;
|
||||||
|
BigDecimal roundedTotal = BigDecimal.ZERO;
|
||||||
|
for (final Map<String, Object> row : due) {
|
||||||
|
final BigDecimal gross = (BigDecimal) row.get("gross_interest");
|
||||||
|
final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||||
|
row.put("rounded_interest", rounded);
|
||||||
|
grossTotal = grossTotal.add(gross);
|
||||||
|
roundedTotal = roundedTotal.add(rounded);
|
||||||
|
}
|
||||||
|
final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||||
|
final BigDecimal residual = pooled.subtract(roundedTotal);
|
||||||
|
if (residual.signum() != 0) {
|
||||||
|
final Map<String, Object> largest = due.stream().max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance")))
|
||||||
|
.orElseThrow();
|
||||||
|
largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual));
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long reportId = jdbcTemplate.queryForObject("""
|
||||||
|
INSERT INTO m_savings_interest_posting_report
|
||||||
|
(savings_product_id, office_id, posting_date, status, total_interest, account_count, payable_gl_account_id,
|
||||||
|
paid_gl_account_id, generation_mode, generated_by, generated_at)
|
||||||
|
VALUES (?, ?, ?, 'GENERATED', ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING id
|
||||||
|
""", Long.class, productId, officeId, postingDate, pooled, due.size(), gls.payableGl(), gls.paidGl(), mode, userId);
|
||||||
|
|
||||||
|
for (final Map<String, Object> row : due) {
|
||||||
|
final BigDecimal before = ((BigDecimal) row.get("closing_balance")).setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||||
|
final BigDecimal interest = (BigDecimal) row.get("rounded_interest");
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_savings_interest_posting_report_line
|
||||||
|
(report_id, savings_account_id, account_no, client_name, office_id, interest_amount, balance_before,
|
||||||
|
balance_after, ledger_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", reportId, row.get("savings_account_id"), row.get("account_no"), row.get("client_name"), officeId, interest,
|
||||||
|
before, before.add(interest), row.get("ledger_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Savings interest report {}: product {} branch {} on {} ({}) — {} across {} accounts", reportId, productId, officeId,
|
||||||
|
postingDate, mode, pooled, due.size());
|
||||||
|
return reportId;
|
||||||
|
}
|
||||||
|
}
|
||||||
+240
-216
@@ -21,7 +21,6 @@ package org.apache.fineract.portfolio.savings.dailyinterest;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -29,6 +28,8 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
|
||||||
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
|
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
|
||||||
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
|
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -42,21 +43,24 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
|
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
|
||||||
* holds the figure that would say so.
|
* holds the figure that would say so.
|
||||||
*
|
*
|
||||||
* This settles the debt the accrual has already recognised: the product's total moves out of the
|
* The product's total is raised as an expense and credited to the liability the product names as its
|
||||||
* liability the product names as its Savings Interest Payable, into the account it is paid out of,
|
* Savings Interest Payable, moves from there into the clearing account it is paid out of, and only
|
||||||
* and only then to the members:
|
* then to the members:
|
||||||
*
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
* Dr Savings Interest Payable (liability, from the product)
|
* Dr Interest On Savings (expense, from the product) cash-accounted products only
|
||||||
* Cr Savings Interest Paid on its way out
|
* Cr Savings Interest Payable (liability, from the product)
|
||||||
|
*
|
||||||
|
* Dr Savings Interest Payable
|
||||||
|
* Cr Savings Interest Paid (liability clearing) on its way out
|
||||||
*
|
*
|
||||||
* Dr Savings Interest Paid (per member)
|
* Dr Savings Interest Paid (per member)
|
||||||
* Cr Savings Control (per member, from the product) in the member's hands
|
* Cr Savings Control (per member, from the product) in the member's hands
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
* The interest expense is not raised here. It is recognised as the interest accrues, day by day, and
|
* The payable and paid accounts each close the posting at nil, so the only lasting effect is the
|
||||||
* this is the settlement of what that recognition already booked - raising the expense again at
|
* expense against the members' balances, and a liability report reading the payable is unaffected.
|
||||||
* payout would put the same interest on the P&L twice.
|
* The first pair is skipped for an accrual-accounted product, whose accruals already raised it.
|
||||||
*
|
*
|
||||||
* Every account named above is read from the product's own GL configuration; none is written into
|
* Every account named above is read from the product's own GL configuration; none is written into
|
||||||
* this class. The payable comes from the Interest Payable row on the product's Accounting tab, and
|
* this class. The payable comes from the Interest Payable row on the product's Accounting tab, and
|
||||||
@@ -113,231 +117,259 @@ public class SavingsPooledInterestPostingService {
|
|||||||
* Accounting tab, which is where a bank picks the account interest is owed from.
|
* Accounting tab, which is where a bank picks the account interest is owed from.
|
||||||
*/
|
*/
|
||||||
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE = 17;
|
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE = 17;
|
||||||
|
/** {@code CashAccountsForSavings.INTEREST_ON_SAVINGS} — the expense every interest posting is charged to. */
|
||||||
|
private static final int GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS = 3;
|
||||||
|
/** m_savings_product.accounting_type for cash-based accounting. */
|
||||||
|
private static final int ACCOUNTING_TYPE_CASH = 2;
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/** The payable and paid accounts a product's interest passes through. */
|
||||||
|
public record PostingGlAccounts(Long payableGl, Long paidGl) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts one product's interest for one posting date.
|
* The payable and paid accounts, as the product maps them now.
|
||||||
*
|
|
||||||
* @return the amount distributed, or zero if there was nothing to do
|
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* REQUIRED, not REQUIRES_NEW.
|
* The product's Accounting tab is the authority: the standard Interest Payable row first, then
|
||||||
|
* the reserved "Savings Interest Payable" mapping. Where a product sets neither, the payable falls
|
||||||
|
* back to its Savings Control; the paid leg falls back to the payable, which collapses stage 1 and
|
||||||
|
* leaves the two-legged posting a product without these mappings has always had.
|
||||||
|
*/
|
||||||
|
public PostingGlAccounts resolvePostingGlAccounts(final long productId) {
|
||||||
|
// All three are required. Falling back to Savings Control when one was left blank posted the
|
||||||
|
// interest straight from expense to the member with no payable or paid leg at all, and nobody
|
||||||
|
// could tell from the entries that the product had simply lost its mappings.
|
||||||
|
mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS);
|
||||||
|
final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
|
||||||
|
namedGlAccount(productId, MAPPING_INTEREST_PAYABLE));
|
||||||
|
final Long paidGl = namedGlAccount(productId, MAPPING_INTEREST_PAID);
|
||||||
|
if (payableGl == null || paidGl == null) {
|
||||||
|
final String productName = jdbcTemplate.queryForObject("SELECT name FROM m_savings_product WHERE id = ?", String.class,
|
||||||
|
productId);
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.gl.not.mapped", "Savings product '"
|
||||||
|
+ productName + "' has no " + (payableGl == null ? "Savings Interest Payable" : "Savings Interest Paid")
|
||||||
|
+ " GL mapped. Set it under the product's Accounting and generate the report again.");
|
||||||
|
}
|
||||||
|
return new PostingGlAccounts(payableGl, paidGl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts one approved interest posting report, paying each account exactly what was approved.
|
||||||
*
|
*
|
||||||
* One product's failure must not roll back another's payment, and REQUIRED gives that already:
|
* <p>
|
||||||
* the orchestrator calls this outside any transaction of its own, so each product opens and
|
* A report belongs to one branch and one product, and every leg it raises is in that branch, so
|
||||||
* commits its own. REQUIRES_NEW would too — but it also suspends whatever transaction is
|
* the branch's interest cost sits in its own P&L and its payable and paid accounts close at nil
|
||||||
* running and waits for locks that transaction may hold. The accrual step immediately before
|
* in its own books:
|
||||||
* writes every account's ledger row, and this method reads and updates those same rows: with
|
*
|
||||||
* REQUIRES_NEW the new transaction sat waiting for locks the caller's own thread was holding,
|
* <pre>
|
||||||
* and the job hung with nothing in the log and its currently_running flag left set.
|
* Dr Interest On Savings (branch total) → Cr Savings Interest Payable
|
||||||
|
* Dr Savings Interest Payable → Cr Savings Interest Paid
|
||||||
|
* Dr Savings Interest Paid (once) → Cr each customer of the branch
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Nothing is recomputed. The report's lines are the authority: interest that accrued after the
|
||||||
|
* report was generated is not added, because the approver signed off on these figures and not on
|
||||||
|
* others. The payable and paid accounts are those recorded on the report when it was generated.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Everything is dated the day it is actually posted — the business date — not the cycle's end: a
|
||||||
|
* branch that was shut on its posting date posts when it next closes, into a day that is still
|
||||||
|
* open, and the narration names the cycle the interest is for.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The whole report settles in one transaction, so a failure leaves nobody half paid and the report
|
||||||
|
* still APPROVED for the next attempt.
|
||||||
|
*
|
||||||
|
* @return the amount distributed
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public BigDecimal postForProduct(final long productId, final LocalDate postingDate) {
|
public BigDecimal postReport(final long reportId) {
|
||||||
final Long completed = jdbcTemplate.queryForObject("""
|
final List<Map<String, Object>> reports = jdbcTemplate.queryForList("""
|
||||||
SELECT COUNT(*) FROM m_savings_interest_posting_run
|
SELECT id, savings_product_id, office_id, posting_date, status, total_interest, payable_gl_account_id,
|
||||||
WHERE savings_product_id = ? AND posting_date = ? AND status = 'COMPLETED'
|
paid_gl_account_id
|
||||||
""", Long.class, productId, postingDate);
|
FROM m_savings_interest_posting_report
|
||||||
if (completed != null && completed > 0) {
|
WHERE id = ?
|
||||||
log.info("Savings interest posting: product {} already completed for {}", productId, postingDate);
|
FOR UPDATE
|
||||||
return BigDecimal.ZERO;
|
""", reportId);
|
||||||
|
if (reports.isEmpty()) {
|
||||||
|
throw new IllegalStateException("Savings interest posting report " + reportId + " does not exist");
|
||||||
|
}
|
||||||
|
final Map<String, Object> report = reports.get(0);
|
||||||
|
if (!"APPROVED".equals(report.get("status"))) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Savings interest posting report " + reportId + " is " + report.get("status") + ", not APPROVED");
|
||||||
|
}
|
||||||
|
final long productId = ((Number) report.get("savings_product_id")).longValue();
|
||||||
|
final LocalDate cycleDate = ((java.sql.Date) report.get("posting_date")).toLocalDate();
|
||||||
|
final LocalDate postedOn = DateUtils.getBusinessLocalDate();
|
||||||
|
final BigDecimal pooled = ((BigDecimal) report.get("total_interest")).setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||||
|
|
||||||
|
final List<Map<String, Object>> lines = jdbcTemplate.queryForList("""
|
||||||
|
SELECT l.id AS line_id, l.savings_account_id, l.office_id, l.interest_amount, l.savings_transaction_id,
|
||||||
|
sa.currency_code
|
||||||
|
FROM m_savings_interest_posting_report_line l
|
||||||
|
JOIN m_savings_account sa ON sa.id = l.savings_account_id
|
||||||
|
WHERE l.report_id = ?
|
||||||
|
ORDER BY l.savings_account_id
|
||||||
|
""", reportId);
|
||||||
|
if (lines.isEmpty()) {
|
||||||
|
throw new IllegalStateException("Savings interest posting report " + reportId + " has no accounts to pay");
|
||||||
|
}
|
||||||
|
final long officeId = report.get("office_id") != null ? ((Number) report.get("office_id")).longValue()
|
||||||
|
: ((Number) lines.get(0).get("office_id")).longValue();
|
||||||
|
|
||||||
|
final long runId = openRun(reportId, productId, officeId, cycleDate, lines.size());
|
||||||
|
|
||||||
|
final PostingGlAccounts current = resolvePostingGlAccounts(productId);
|
||||||
|
final Long payableGl = report.get("payable_gl_account_id") != null
|
||||||
|
? ((Number) report.get("payable_gl_account_id")).longValue()
|
||||||
|
: current.payableGl();
|
||||||
|
final Long paidGl = report.get("paid_gl_account_id") != null ? ((Number) report.get("paid_gl_account_id")).longValue()
|
||||||
|
: current.paidGl();
|
||||||
|
|
||||||
|
final String currency = (String) lines.get(0).get("currency_code");
|
||||||
|
// One per report, and deterministic: a retried posting carries the same reference as the legs a
|
||||||
|
// failed attempt rolled back, and two branches posting the same product never share one.
|
||||||
|
final String reference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postedOn, reportId);
|
||||||
|
final String narration = "Savings interest for cycle ending " + cycleDate;
|
||||||
|
|
||||||
|
// Stage 0: the interest cost is raised and the total credited to the payable. Nothing else
|
||||||
|
// credits it on a cash-accounted product — the daily accrual writes no journal entry. An
|
||||||
|
// accrual-accounted product has already done this through its accrual postings.
|
||||||
|
if (isCashAccounted(productId)) {
|
||||||
|
final Long expenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_INTEREST_ON_SAVINGS);
|
||||||
|
journalEntry(officeId, currency, expenseGl, postedOn, pooled, true, reference, null, null, narration);
|
||||||
|
journalEntry(officeId, currency, payableGl, postedOn, pooled, false, reference, null, null, narration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every account whose posting date has arrived or passed and has not yet been paid.
|
// Stage 1: the total moves out of the payable and into the account the payout is made from.
|
||||||
//
|
// Skipped where both are the same account, which would debit and credit it for nothing.
|
||||||
// Read by posting_date rather than by the run date: the ledger keeps one row per account and
|
final boolean stage1Applies = !payableGl.equals(paidGl);
|
||||||
// overwrites it daily, so on the day after a quarter end the row saying "this was a posting
|
|
||||||
// day" no longer exists. Matching on the run date alone meant a quarter end the job did not
|
|
||||||
// run on was never paid at all — the cycle simply rolled over with the interest carried into
|
|
||||||
// it. A posting that is owed stays owed until it is made, which is how an overnight batch has
|
|
||||||
// to behave when a night is missed.
|
|
||||||
//
|
|
||||||
// Anything already carrying a posting transaction is skipped, so a resumed run cannot pay
|
|
||||||
// somebody a second time.
|
|
||||||
//
|
|
||||||
// A savings account has no office of its own: it belongs to a client, or to a group when
|
|
||||||
// there is no client, and the office is theirs. The journal entries have to be raised
|
|
||||||
// against that office or they land in the wrong branch's books.
|
|
||||||
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
|
|
||||||
SELECT d.id AS ledger_id, d.savings_account_id, d.closing_balance,
|
|
||||||
d.cycle_cumulative_interest AS gross_interest,
|
|
||||||
COALESCE(c.office_id, g.office_id) AS office_id, sa.currency_code,
|
|
||||||
sa.currency_digits
|
|
||||||
FROM m_savings_daily_interest d
|
|
||||||
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
|
||||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
|
||||||
LEFT JOIN m_group g ON g.id = sa.group_id
|
|
||||||
WHERE d.savings_product_id = ? AND d.posting_date <= ?
|
|
||||||
AND d.savings_transaction_id IS NULL
|
|
||||||
AND d.cycle_cumulative_interest > 0
|
|
||||||
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
|
|
||||||
ORDER BY d.savings_account_id
|
|
||||||
""", productId, postingDate);
|
|
||||||
|
|
||||||
if (due.isEmpty()) {
|
|
||||||
log.info("Savings interest posting: nothing due for product {} on {}", productId, postingDate);
|
|
||||||
return BigDecimal.ZERO;
|
|
||||||
}
|
|
||||||
|
|
||||||
final long runId = openRun(productId, postingDate, due.size());
|
|
||||||
|
|
||||||
// Round each member's share, then make the rounded shares add up. The residual is the
|
|
||||||
// difference between what the pool holds and what the members would otherwise receive.
|
|
||||||
BigDecimal grossTotal = BigDecimal.ZERO;
|
|
||||||
BigDecimal roundedTotal = BigDecimal.ZERO;
|
|
||||||
for (final Map<String, Object> row : due) {
|
|
||||||
final BigDecimal gross = (BigDecimal) row.get("gross_interest");
|
|
||||||
final BigDecimal rounded = gross.setScale(MONEY_SCALE, MONEY_ROUNDING);
|
|
||||||
row.put("rounded_interest", rounded);
|
|
||||||
grossTotal = grossTotal.add(gross);
|
|
||||||
roundedTotal = roundedTotal.add(rounded);
|
|
||||||
}
|
|
||||||
final BigDecimal pooled = grossTotal.setScale(MONEY_SCALE, MONEY_ROUNDING);
|
|
||||||
final BigDecimal residual = pooled.subtract(roundedTotal);
|
|
||||||
|
|
||||||
if (residual.compareTo(BigDecimal.ZERO) != 0) {
|
|
||||||
// To the largest balance: the account where a paisa is proportionally smallest.
|
|
||||||
final Map<String, Object> largest = due.stream()
|
|
||||||
.max(Comparator.comparing(r -> (BigDecimal) r.get("closing_balance"))).orElseThrow();
|
|
||||||
largest.put("rounded_interest", ((BigDecimal) largest.get("rounded_interest")).add(residual));
|
|
||||||
log.info("Savings interest posting: residual {} assigned to account {}", residual,
|
|
||||||
largest.get("savings_account_id"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The three accounts the bill passes through on its way to the members. Every one is read
|
|
||||||
// from the product's own configuration, so a bank on its own chart of accounts is posted to
|
|
||||||
// the accounts it chose. No GL code is named in this class.
|
|
||||||
//
|
|
||||||
// The product's Accounting tab is the authority, and it is consulted first for all three:
|
|
||||||
// Interest On Savings for the expense, and the standard Interest Payable row for the
|
|
||||||
// payable. Those are the rows a bank actually fills in on the product screen.
|
|
||||||
//
|
|
||||||
// The reserved names in the product's other GL mappings are consulted only after that, as a
|
|
||||||
// fallback for a product configured before Interest Payable was mapped, and for the paid leg,
|
|
||||||
// which has no standard row of its own.
|
|
||||||
//
|
|
||||||
// Where a product sets none of them the bill still has somewhere to go: the payable falls
|
|
||||||
// back to the product's Savings Control (interest owed is a liability either way), and the
|
|
||||||
// paid leg falls back to the payable, which collapses stage 2 and leaves the two-legged
|
|
||||||
// posting a product without these mappings has always had.
|
|
||||||
final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
|
|
||||||
namedGlAccount(productId, MAPPING_INTEREST_PAYABLE),
|
|
||||||
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL));
|
|
||||||
final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl);
|
|
||||||
|
|
||||||
final Long officeId = ((Number) due.get(0).get("office_id")).longValue();
|
|
||||||
final String currency = (String) due.get(0).get("currency_code");
|
|
||||||
// Deterministic, not counter-based: a run that stops halfway is resumed rather than restarted,
|
|
||||||
// and the resumed legs have to carry the same reference as the legs already posted, or one
|
|
||||||
// interest posting ends up split across two transaction ids.
|
|
||||||
final String poolReference = LedgerTransactionId.forDatedSeries(LedgerTxnPrefix.SAVINGS_INTEREST_POOLED, postingDate,
|
|
||||||
productId);
|
|
||||||
|
|
||||||
// Stage 1: the debt the accrual has been building moves out of the payable and into the
|
|
||||||
// account the payout is made from, so what is still merely owed and what is on its way to
|
|
||||||
// the members are two separate balances rather than one.
|
|
||||||
//
|
|
||||||
// The interest expense itself is not raised here. It is recognised as the interest accrues,
|
|
||||||
// day by day, and posting is the settlement of that recognised debt — raising the expense
|
|
||||||
// again at payout would count the same interest twice on the P&L.
|
|
||||||
//
|
|
||||||
// Skipped where the product names no separate paid account: both legs then resolve to the
|
|
||||||
// payable and this would debit and credit one account for the same amount, saying nothing.
|
|
||||||
final boolean payableIsPaid = payableGl.equals(paidGl);
|
|
||||||
final boolean stage1Applies = !payableIsPaid;
|
|
||||||
if (stage1Applies) {
|
if (stage1Applies) {
|
||||||
journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null);
|
journalEntry(officeId, currency, payableGl, postedOn, pooled, true, reference, null, null, narration);
|
||||||
journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null);
|
journalEntry(officeId, currency, paidGl, postedOn, pooled, false, reference, null, null, narration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where stage 1 ran, the pool is now sitting in the paid account and that is what stage 2
|
|
||||||
// draws down. Where it did not, the pool is still in the payable, so stage 2 has to draw
|
|
||||||
// from there instead — debiting the paid account would take the money out of an account
|
|
||||||
// nothing put it into, leaving the payable standing as a debt that was in fact settled.
|
|
||||||
final Long payoutFromGl = stage1Applies ? paidGl : payableGl;
|
final Long payoutFromGl = stage1Applies ? paidGl : payableGl;
|
||||||
|
|
||||||
// Stage 2: out to the members, one at a time.
|
// Stage 2: one debit for the branch's total, and under it a credit to each customer. Every leg
|
||||||
|
// carries the report's one transaction id, and every credit names its account and interest
|
||||||
|
// transaction, so who was paid what reads back from the journal alone.
|
||||||
|
journalEntry(officeId, currency, payoutFromGl, postedOn, pooled, true, reference, null, null, narration);
|
||||||
BigDecimal distributed = BigDecimal.ZERO;
|
BigDecimal distributed = BigDecimal.ZERO;
|
||||||
for (final Map<String, Object> row : due) {
|
for (final Map<String, Object> line : lines) {
|
||||||
final BigDecimal amount = (BigDecimal) row.get("rounded_interest");
|
distributed = distributed
|
||||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
.add(payMember(line, productId, officeId, cycleDate, postedOn, currency, reference, narration, runId));
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final long accountId = ((Number) row.get("savings_account_id")).longValue();
|
|
||||||
final BigDecimal newBalance = ((BigDecimal) row.get("closing_balance")).add(amount);
|
|
||||||
// The member's own branch, which need not be the branch the pool was raised in.
|
|
||||||
final long accountOfficeId = ((Number) row.get("office_id")).longValue();
|
|
||||||
|
|
||||||
final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount,
|
|
||||||
newBalance);
|
|
||||||
|
|
||||||
journalEntry(accountOfficeId, currency, payoutFromGl, postingDate, amount, true, poolReference,
|
|
||||||
accountId);
|
|
||||||
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
|
|
||||||
accountId, transactionId);
|
|
||||||
|
|
||||||
// closing_balance is raised here because the interest transaction has just been written
|
|
||||||
// and the accrual for this day has already run: nothing else will fold it in before the
|
|
||||||
// next day reads this row as its opening.
|
|
||||||
jdbcTemplate.update("""
|
|
||||||
UPDATE m_savings_daily_interest
|
|
||||||
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
|
|
||||||
closing_balance = closing_balance + ?
|
|
||||||
WHERE id = ?
|
|
||||||
""", amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue());
|
|
||||||
|
|
||||||
// Both interest columns move together, and that is not a duplicate.
|
|
||||||
//
|
|
||||||
// total_interest_earned_derived is the interest the account has earned to date;
|
|
||||||
// total_interest_posted_derived is how much of that has been paid into the balance.
|
|
||||||
// Screens and validate()'s own check read the unposted interest as earned - posted, so
|
|
||||||
// raising only `posted` drives that difference negative: an account paid 26.52 with
|
|
||||||
// `earned` still at zero reports its unposted interest as -26.52, and the account screen
|
|
||||||
// showed exactly that. The accrual writes pending interest to the ledger and never to
|
|
||||||
// `earned`, so this posting is the only place `earned` is realised.
|
|
||||||
jdbcTemplate.update("""
|
|
||||||
UPDATE m_savings_account
|
|
||||||
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
|
|
||||||
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?,
|
|
||||||
total_interest_earned_derived = COALESCE(total_interest_earned_derived, 0) + ?
|
|
||||||
WHERE id = ?
|
|
||||||
""", amount, amount, amount, accountId);
|
|
||||||
|
|
||||||
distributed = distributed.add(amount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The whole point of the two control accounts: if this does not hold, nothing commits.
|
// If the credits do not add up to the debit, nothing commits.
|
||||||
if (pooled.compareTo(distributed) != 0) {
|
if (pooled.compareTo(distributed) != 0) {
|
||||||
throw new IllegalStateException(String.format(
|
throw new IllegalStateException(String.format(
|
||||||
"Savings interest posting for product %d on %s did not balance: pooled %s, distributed %s",
|
"Savings interest posting report %d did not balance: total %s, distributed %s", reportId, pooled, distributed));
|
||||||
productId, postingDate, pooled, distributed));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return completeReport(reportId, productId, cycleDate, pooled, distributed, runId, lines.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One customer's credit: the interest transaction, the Savings Control credit naming the account,
|
||||||
|
* and the account, ledger and report line brought up to date. The branch debit is raised by the
|
||||||
|
* caller.
|
||||||
|
*
|
||||||
|
* @return the amount credited, zero for a line with nothing to pay
|
||||||
|
*/
|
||||||
|
private BigDecimal payMember(final Map<String, Object> line, final long productId, final long officeId,
|
||||||
|
final LocalDate cycleDate, final LocalDate postedOn, final String currency, final String reference,
|
||||||
|
final String narration, final long runId) {
|
||||||
|
final BigDecimal amount = ((BigDecimal) line.get("interest_amount")).setScale(MONEY_SCALE, MONEY_ROUNDING);
|
||||||
|
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
final long accountId = ((Number) line.get("savings_account_id")).longValue();
|
||||||
|
|
||||||
|
// A resumed or duplicated attempt must not pay anybody twice. The line itself records the
|
||||||
|
// payment, so this holds even where one account has two cycles posted on the same day.
|
||||||
|
if (line.get("savings_transaction_id") != null) {
|
||||||
|
throw new IllegalStateException("Savings account " + accountId + " was already paid by this report");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The balance as it stands now, not as the report showed it: money moved since the report
|
||||||
|
// was generated, and the transaction's running balance has to follow the account.
|
||||||
|
final BigDecimal currentBalance = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COALESCE(account_balance_derived, 0) FROM m_savings_account WHERE id = ?", BigDecimal.class, accountId);
|
||||||
|
final BigDecimal newBalance = currentBalance.add(amount);
|
||||||
|
|
||||||
|
final long transactionId = createInterestPostingTransaction(accountId, officeId, postedOn, amount, newBalance);
|
||||||
|
|
||||||
|
final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL);
|
||||||
|
journalEntry(officeId, currency, savingsControlGl, postedOn, amount, false, reference, accountId, transactionId, narration);
|
||||||
|
|
||||||
|
// Settles the ledger row where it still holds the cycle the report was drawn from. Where the
|
||||||
|
// nightly run has already moved it into the next cycle — a branch that was shut on its posting
|
||||||
|
// date and posts days later — that row is the new cycle's and must not be marked paid; it only
|
||||||
|
// takes the money, because the next day's accrual opens from its closing balance.
|
||||||
|
final int settled = jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_daily_interest
|
||||||
|
SET posted_amount = ?, savings_transaction_id = ?, posting_run_id = ?,
|
||||||
|
closing_balance = closing_balance + ?
|
||||||
|
WHERE savings_account_id = ? AND savings_transaction_id IS NULL AND posting_date = ?
|
||||||
|
""", amount, transactionId, runId, amount, accountId, cycleDate);
|
||||||
|
if (settled == 0) {
|
||||||
|
jdbcTemplate.update("UPDATE m_savings_daily_interest SET closing_balance = closing_balance + ? WHERE savings_account_id = ?",
|
||||||
|
amount, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Earned is only ever raised to what has now been posted, never added to: the engine may
|
||||||
|
// already have counted this interest as earned when the account was last recalculated,
|
||||||
|
// and adding on top of that doubled it (1.37 paid showed as 2.74 earned). Screens read the
|
||||||
|
// unposted interest as earned - posted, so earned must not fall below posted either.
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_account
|
||||||
|
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
|
||||||
|
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?,
|
||||||
|
total_interest_earned_derived = GREATEST(COALESCE(total_interest_earned_derived, 0),
|
||||||
|
COALESCE(total_interest_posted_derived, 0) + ?)
|
||||||
|
WHERE id = ?
|
||||||
|
""", amount, amount, amount, accountId);
|
||||||
|
|
||||||
|
jdbcTemplate.update("UPDATE m_savings_interest_posting_report_line SET savings_transaction_id = ? WHERE id = ?",
|
||||||
|
transactionId, ((Number) line.get("line_id")).longValue());
|
||||||
|
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal completeReport(final long reportId, final long productId, final LocalDate cycleDate, final BigDecimal pooled,
|
||||||
|
final BigDecimal distributed, final long runId, final int accountCount) {
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
UPDATE m_savings_interest_posting_run
|
UPDATE m_savings_interest_posting_run
|
||||||
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = ?,
|
SET pooled_amount = ?, distributed_amount = ?, rounding_residual = 0,
|
||||||
status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP
|
status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""", pooled, distributed, residual, runId);
|
""", pooled, distributed, runId);
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_savings_interest_posting_report
|
||||||
|
SET status = 'POSTED', posted_at = CURRENT_TIMESTAMP, posting_run_id = ?, failure_reason = NULL
|
||||||
|
WHERE id = ?
|
||||||
|
""", runId, reportId);
|
||||||
|
|
||||||
log.info("Savings interest posting: product {} on {} paid {} across {} accounts (residual {})", productId,
|
log.info("Savings interest posting: report {} (product {}, cycle {}) paid {} across {} accounts", reportId, productId,
|
||||||
postingDate, distributed, due.size(), residual);
|
cycleDate, distributed, accountCount);
|
||||||
return distributed;
|
return distributed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private long openRun(final long productId, final LocalDate postingDate, final int accountCount) {
|
/** One run per report. A retry of the same report reuses its run. */
|
||||||
|
private long openRun(final long reportId, final long productId, final long officeId, final LocalDate cycleDate,
|
||||||
|
final int accountCount) {
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO m_savings_interest_posting_run
|
INSERT INTO m_savings_interest_posting_run
|
||||||
(savings_product_id, posting_date, account_count, status)
|
(report_id, savings_product_id, office_id, posting_date, account_count, status)
|
||||||
VALUES (?, ?, ?, 'PENDING')
|
VALUES (?, ?, ?, ?, ?, 'PENDING')
|
||||||
ON CONFLICT (savings_product_id, posting_date) DO UPDATE SET
|
ON CONFLICT (report_id) DO UPDATE SET
|
||||||
account_count = EXCLUDED.account_count, status = 'PENDING',
|
account_count = EXCLUDED.account_count, status = 'PENDING',
|
||||||
started_at = CURRENT_TIMESTAMP, failure_reason = NULL
|
started_at = CURRENT_TIMESTAMP, failure_reason = NULL
|
||||||
""", productId, postingDate, accountCount);
|
""", reportId, productId, officeId, cycleDate, accountCount);
|
||||||
return jdbcTemplate.queryForObject("""
|
return jdbcTemplate.queryForObject("SELECT id FROM m_savings_interest_posting_run WHERE report_id = ?", Long.class, reportId);
|
||||||
SELECT id FROM m_savings_interest_posting_run
|
|
||||||
WHERE savings_product_id = ? AND posting_date = ?
|
|
||||||
""", Long.class, productId, postingDate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate date,
|
private long createInterestPostingTransaction(final long accountId, final long officeId, final LocalDate date,
|
||||||
@@ -357,19 +389,6 @@ public class SavingsPooledInterestPostingService {
|
|||||||
""", Long.class, accountId, date, INTEREST_POSTING);
|
""", Long.class, accountId, date, INTEREST_POSTING);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The member's own leg: the product's Savings Control account, credited per account. */
|
|
||||||
private void creditMemberSavingsControl(final long officeId, final String currency, final long productId,
|
|
||||||
final LocalDate date, final BigDecimal amount, final String reference, final long accountId,
|
|
||||||
final long transactionId) {
|
|
||||||
final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL);
|
|
||||||
journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
|
|
||||||
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId) {
|
|
||||||
journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One journal entry leg.
|
* One journal entry leg.
|
||||||
*
|
*
|
||||||
@@ -384,7 +403,7 @@ public class SavingsPooledInterestPostingService {
|
|||||||
*/
|
*/
|
||||||
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
|
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
|
||||||
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
|
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
|
||||||
final Long transactionId) {
|
final Long transactionId, final String narration) {
|
||||||
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
|
||||||
jdbcTemplate.update("""
|
jdbcTemplate.update("""
|
||||||
INSERT INTO acc_gl_journal_entry
|
INSERT INTO acc_gl_journal_entry
|
||||||
@@ -395,8 +414,7 @@ public class SavingsPooledInterestPostingService {
|
|||||||
created_on_utc, last_modified_on_utc, submitted_on_date)
|
created_on_utc, last_modified_on_utc, submitted_on_date)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
|
||||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
|
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
|
||||||
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount,
|
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount, narration,
|
||||||
debit ? "Savings interest posting" : "Savings interest posted",
|
|
||||||
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
|
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
|
||||||
systemUserId(), systemUserId(), date);
|
systemUserId(), systemUserId(), date);
|
||||||
}
|
}
|
||||||
@@ -487,6 +505,12 @@ public class SavingsPooledInterestPostingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The first configured account of those given, in order of preference. */
|
/** The first configured account of those given, in order of preference. */
|
||||||
|
private boolean isCashAccounted(final long productId) {
|
||||||
|
final Integer accountingType = jdbcTemplate.queryForObject("SELECT accounting_type FROM m_savings_product WHERE id = ?",
|
||||||
|
Integer.class, productId);
|
||||||
|
return accountingType != null && accountingType == ACCOUNTING_TYPE_CASH;
|
||||||
|
}
|
||||||
|
|
||||||
private static Long firstNonNull(final Long... candidates) {
|
private static Long firstNonNull(final Long... candidates) {
|
||||||
for (final Long candidate : candidates) {
|
for (final Long candidate : candidates) {
|
||||||
if (candidate != null) {
|
if (candidate != null) {
|
||||||
|
|||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The savings interest posting reports, per branch and product: list, detail, generate, approve, the
|
||||||
|
* day-close check, and the head office's branch-wise summary.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Posting is not offered here. A branch's approved reports are posted when that branch closes the
|
||||||
|
* day, so there is exactly one way money leaves the payable.
|
||||||
|
*/
|
||||||
|
@Path("/v1/savingsinterestpostingreports")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class SavingsInterestPostingReportApiResource {
|
||||||
|
|
||||||
|
private static final String RESOURCE = "SAVINGSINTERESTREPORT";
|
||||||
|
|
||||||
|
private final SavingsInterestPostingReportService reportService;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final FromJsonHelper fromJsonHelper;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
/** A branch user sees their own branch; a head office user every branch, or one with officeId. */
|
||||||
|
@GET
|
||||||
|
public String list(@QueryParam("postingDate") final String postingDate, @QueryParam("status") final String status,
|
||||||
|
@QueryParam("productId") final Long productId, @QueryParam("officeId") final Long officeId) {
|
||||||
|
final AppUser user = context.authenticatedUser();
|
||||||
|
user.validateHasReadPermission(RESOURCE);
|
||||||
|
return gson.toJson(reportService.list(parseDate(postingDate), status, productId, officeId, user).stream().map(this::plain).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
|
||||||
|
@GET
|
||||||
|
@Path("/due-without-report")
|
||||||
|
public String dueWithoutReport(@QueryParam("postingDate") final String postingDate) {
|
||||||
|
final AppUser user = context.authenticatedUser();
|
||||||
|
user.validateHasReadPermission(RESOURCE);
|
||||||
|
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
|
||||||
|
return gson.toJson(reportService.dueWithoutReport(date, user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What stops a branch closing the business day: its own reports due and not yet approved. The
|
||||||
|
* day-close screen reads it to warn before the close is attempted.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/day-close-check")
|
||||||
|
public String dayCloseCheck(@QueryParam("date") final String date, @QueryParam("officeId") final Long officeIdParam) {
|
||||||
|
final AppUser user = context.authenticatedUser();
|
||||||
|
final LocalDate businessDate = date == null || date.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(date);
|
||||||
|
final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
|
||||||
|
final List<String> blocking = reportService.reportsBlockingDayClose(officeId, businessDate);
|
||||||
|
final Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("businessDate", businessDate.toString());
|
||||||
|
result.put("canClose", blocking.isEmpty());
|
||||||
|
result.put("pendingProducts", blocking);
|
||||||
|
return gson.toJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The head office's view: per branch and product, customers paid and interest, over a date range. */
|
||||||
|
@GET
|
||||||
|
@Path("/summary")
|
||||||
|
public String summary(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||||
|
@QueryParam("productId") final Long productId) {
|
||||||
|
final AppUser user = context.authenticatedUser();
|
||||||
|
user.validateHasReadPermission(RESOURCE);
|
||||||
|
if (!reportService.isHeadOffice(user.getOffice().getId())) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.summary.head.office.only",
|
||||||
|
"The branch-wise interest summary is for the head office.");
|
||||||
|
}
|
||||||
|
return gson.toJson(reportService.summary(parseDate(fromDate), parseDate(toDate), productId).stream().map(this::plain).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{reportId}")
|
||||||
|
public String detail(@PathParam("reportId") final Long reportId) {
|
||||||
|
context.authenticatedUser().validateHasReadPermission(RESOURCE);
|
||||||
|
final Map<String, Object> report = plain(reportService.detail(reportId));
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
final List<Map<String, Object>> lines = (List<Map<String, Object>>) report.get("lines");
|
||||||
|
report.put("lines", lines.stream().map(this::plain).toList());
|
||||||
|
return gson.toJson(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code ?command=regenerate} with {@code {"productId": .., "officeId": .., "postingDate": "yyyy-MM-dd"}}. */
|
||||||
|
@POST
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
public String command(@QueryParam("command") final String command, final String body) {
|
||||||
|
if (!"regenerate".equalsIgnoreCase(command)) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command",
|
||||||
|
"Unsupported command: " + command);
|
||||||
|
}
|
||||||
|
final AppUser user = context.authenticatedUser();
|
||||||
|
user.validateHasPermissionTo("REGENERATE_" + RESOURCE);
|
||||||
|
final var root = fromJsonHelper.parse(body);
|
||||||
|
final Long productId = fromJsonHelper.extractLongNamed("productId", root);
|
||||||
|
final Long officeIdParam = fromJsonHelper.extractLongNamed("officeId", root);
|
||||||
|
final String postingDate = fromJsonHelper.extractStringNamed("postingDate", root);
|
||||||
|
if (productId == null) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.product.required", "Which product?");
|
||||||
|
}
|
||||||
|
final Long officeId = officeIdParam != null ? officeIdParam : user.getOffice().getId();
|
||||||
|
final LocalDate date = postingDate == null || postingDate.isBlank() ? DateUtils.getBusinessLocalDate() : parseDate(postingDate);
|
||||||
|
final Long reportId = reportService.regenerate(productId, officeId, date, user);
|
||||||
|
return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.GENERATED));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code ?command=approve}. The report's own branch approves it; see the service for the head office's one exception. */
|
||||||
|
@POST
|
||||||
|
@Path("/{reportId}")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
public String reportCommand(@PathParam("reportId") final Long reportId, @QueryParam("command") final String command) {
|
||||||
|
if (!"approve".equalsIgnoreCase(command)) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.unknown.command",
|
||||||
|
"Unsupported command: " + command);
|
||||||
|
}
|
||||||
|
final AppUser user = context.authenticatedUser();
|
||||||
|
user.validateHasPermissionTo("APPROVE_" + RESOURCE);
|
||||||
|
reportService.approve(reportId, user);
|
||||||
|
return gson.toJson(Map.of("reportId", reportId, "status", SavingsInterestPostingReportService.APPROVED));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate parseDate(final String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(raw.trim());
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.savingsinterestreport.invalid.date",
|
||||||
|
"Dates are expected as yyyy-MM-dd, not " + raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JDBC dates and timestamps as ISO strings; Gson would otherwise write them in its own locale format. */
|
||||||
|
private Map<String, Object> plain(final Map<String, Object> row) {
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
row.forEach((key, value) -> {
|
||||||
|
if (value instanceof java.sql.Date d) {
|
||||||
|
out.put(key, d.toLocalDate().toString());
|
||||||
|
} else if (value instanceof Timestamp t) {
|
||||||
|
out.put(key, t.toLocalDateTime().toString());
|
||||||
|
} else {
|
||||||
|
out.put(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest.jobs;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.jobs.service.JobName;
|
||||||
|
import org.springframework.batch.core.Job;
|
||||||
|
import org.springframework.batch.core.Step;
|
||||||
|
import org.springframework.batch.core.job.builder.JobBuilder;
|
||||||
|
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||||
|
import org.springframework.batch.core.repository.JobRepository;
|
||||||
|
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class SavingsInterestPostingReportConfig {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JobRepository jobRepository;
|
||||||
|
@Autowired
|
||||||
|
private PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
protected Step savingsInterestPostingReportStep(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) {
|
||||||
|
return new StepBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository)
|
||||||
|
.tasklet(savingsInterestPostingReportTasklet, transactionManager).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Job savingsInterestPostingReportJob(SavingsInterestPostingReportTasklet savingsInterestPostingReportTasklet) {
|
||||||
|
return new JobBuilder(JobName.SAVINGS_INTEREST_POSTING_REPORT.name(), jobRepository)
|
||||||
|
.start(savingsInterestPostingReportStep(savingsInterestPostingReportTasklet)).incrementer(new RunIdIncrementer())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.savings.dailyinterest.jobs;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService;
|
||||||
|
import org.springframework.batch.core.StepContribution;
|
||||||
|
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||||
|
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||||
|
import org.springframework.batch.repeat.RepeatStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Noon on the business date: generates the interest posting report for every savings product whose
|
||||||
|
* interest falls due, so it can be reviewed and approved before the branches close the day.
|
||||||
|
*/
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Component
|
||||||
|
public class SavingsInterestPostingReportTasklet implements Tasklet {
|
||||||
|
|
||||||
|
private final SavingsInterestPostingReportService reportService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
|
||||||
|
reportService.generateDue(DateUtils.getBusinessLocalDate());
|
||||||
|
return RepeatStatus.FINISHED;
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -436,8 +436,14 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
|||||||
public List<SavingsAccountData> retrieveAllSavingsDataForInterestPosting(final boolean backdatedTxnsAllowedTill, final int pageSize,
|
public List<SavingsAccountData> retrieveAllSavingsDataForInterestPosting(final boolean backdatedTxnsAllowedTill, final int pageSize,
|
||||||
final Integer status, final Long maxSavingsId) {
|
final Integer status, final Long maxSavingsId) {
|
||||||
LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1);
|
LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1);
|
||||||
|
// A product on the daily-interest timetable is paid by the pooled posting instead
|
||||||
|
// (SavingsPooledInterestPostingService). Posting it here too would pay its members twice.
|
||||||
|
// FD and RD products are never on that timetable, so they still post here. The filter sits inside the paging
|
||||||
|
// subquery so a page is never cut short by accounts that are then dropped.
|
||||||
String sql = "select " + this.savingAccountMapperForInterestPosting.schema()
|
String sql = "select " + this.savingAccountMapperForInterestPosting.schema()
|
||||||
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? limit ?) b on b.id = sa.id ";
|
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? "
|
||||||
|
+ "and not exists (select 1 from m_savings_interest_posting_schedule s "
|
||||||
|
+ "where s.savings_product_id = a.product_id and s.is_active = true) limit ?) b on b.id = sa.id ";
|
||||||
if (backdatedTxnsAllowedTill) {
|
if (backdatedTxnsAllowedTill) {
|
||||||
sql = sql
|
sql = sql
|
||||||
+ "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) ";
|
+ "where (CASE WHEN sa.interest_posted_till_date is not null THEN tr.transaction_date >= sa.interest_posted_till_date ELSE tr.transaction_date >= sa.activatedon_date END) ";
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
|
|||||||
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
||||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009}
|
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
|
||||||
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||||
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
||||||
@@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
|
|||||||
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
||||||
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
||||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009}
|
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
|
||||||
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
||||||
|
|||||||
@@ -144,6 +144,10 @@
|
|||||||
<include file="parts/0582_form_a_saved_report.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0582_form_a_saved_report.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0583_save_rbi_form_a_return_job.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0583_save_rbi_form_a_return_job.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0584_form9_slr_assets.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0584_form9_slr_assets.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0585_form1_statutory_heads.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0586_form9_return_heads.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0587_form9_default_mappings.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0588_form9_map_dividend_round_off.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0582_create_tds_config.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0582_create_tds_config.xml" relativeToChangelogFile="true"/>
|
||||||
<!-- Our local 0575 Loan Originator checker-permission migration collided with dev-latest's own
|
<!-- Our local 0575 Loan Originator checker-permission migration collided with dev-latest's own
|
||||||
0575_rbi_reserve_custom_roles.xml once merged (it was only stashed/uncommitted locally, so it
|
0575_rbi_reserve_custom_roles.xml once merged (it was only stashed/uncommitted locally, so it
|
||||||
@@ -157,4 +161,6 @@
|
|||||||
<include file="parts/0586_add_system_module_checker_permissions.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0586_add_system_module_checker_permissions.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0587_fix_current_product_od_scheme_pending_columns.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0587_fix_current_product_od_scheme_pending_columns.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0588_add_premature_closure_reason.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0588_add_premature_closure_reason.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0589_savings_interest_posting_report.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0590_savings_interest_posting_report_by_branch.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+240
@@ -0,0 +1,240 @@
|
|||||||
|
<?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.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The line items of the statutory Form 1 a co-operative bank actually files - Banking
|
||||||
|
Regulation (Co-operative Societies) Rules 1966, Form 1 under Rule 5, sections 18(1) and
|
||||||
|
24(3) - so each can be mapped to this bank's own GL accounts.
|
||||||
|
|
||||||
|
The return already modelled had one liabilities figure and one holdings figure. The filed
|
||||||
|
form is structured quite differently, and the difference is not cosmetic:
|
||||||
|
|
||||||
|
- Liabilities and assets are each split between the BANKING SYSTEM (part I and part III)
|
||||||
|
and everyone else (part II). The split matters because the two are netted against each
|
||||||
|
other to arrive at the figure the ratios are charged on.
|
||||||
|
|
||||||
|
- Balances with other banks are split again between current accounts (VI) and every other
|
||||||
|
kind of balance (VII), because only the current-account ones count toward the cash
|
||||||
|
reserve, while the rest count toward the liquidity requirement.
|
||||||
|
|
||||||
|
- Part VI and VII name the counterparties the rules name: the Reserve Bank, the State
|
||||||
|
Co-operative Bank and the District Central Co-operative Bank. A single "balances with
|
||||||
|
other banks" head cannot report these separately, and the form requires it.
|
||||||
|
|
||||||
|
Heads are seeded as MEMO. Each one's treatment on the return - added into the base, netted
|
||||||
|
off it, or counted toward a requirement - is a regulatory question this migration is not
|
||||||
|
the place to settle, and a seeded guess would be indistinguishable from a decision the bank
|
||||||
|
made. An administrator changes the usage as each head's role is established.
|
||||||
|
|
||||||
|
No head is mapped to an account: the right account differs per bank, and Basoda's chart is
|
||||||
|
not this tenant's.
|
||||||
|
|
||||||
|
Every changeSet is guarded so this migration is a safe no-op where already applied.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0585-1-seed-form1-banking-system-liabilities">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_role"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_I_%'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<!--
|
||||||
|
Part I: liabilities in India TO the banking system. Reported apart from liabilities to
|
||||||
|
everyone else because the banking system's claims on the bank are netted against the
|
||||||
|
bank's claims on it (part III) before the ratios are charged.
|
||||||
|
-->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM1_I_A_I_SBI_CURRENT"/>
|
||||||
|
<column name="label" value="I.a.i Current account credit balances of SBI and its subsidiaries"/>
|
||||||
|
<column name="description" value="Form 1 Part I.a(i). Credit balances in current accounts that the State Bank of India, its subsidiary banks and the corresponding new banks maintain WITH this bank. A liability to the banking system, not to a depositor."/>
|
||||||
|
<column name="role_group" value="RESERVE"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="500"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM1_I_A_II_OTHER_DEMAND_BS"/>
|
||||||
|
<column name="label" value="I.a.ii Other demand liabilities to the banking system"/>
|
||||||
|
<column name="description" value="Form 1 Part I.a(ii). Demand liabilities owed to banks other than those named in I.a(i)."/>
|
||||||
|
<column name="role_group" value="RESERVE"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="501"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM1_I_B_TIME_BS"/>
|
||||||
|
<column name="label" value="I.b Time liabilities to the banking system"/>
|
||||||
|
<column name="description" value="Form 1 Part I.b. Time liabilities owed to the banking system."/>
|
||||||
|
<column name="role_group" value="RESERVE"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="502"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0585-2-seed-form1-banking-system-assets">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_role"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_III_%'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<!--
|
||||||
|
Part III: assets in India WITH the banking system. Netted against part I to give the net
|
||||||
|
position, which is what the form's item IV reports.
|
||||||
|
-->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM1_III_A_SBI_CURRENT"/>
|
||||||
|
<column name="label" value="III.a Current account balances with SBI and its subsidiaries"/>
|
||||||
|
<column name="description" value="Form 1 Part III.a. Credit balances THIS bank holds in current accounts with the State Bank of India, its subsidiary banks and the corresponding new banks. Item VIII reports the excess of this over I.a(i)."/>
|
||||||
|
<column name="role_group" value="RESERVE"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="510"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM1_III_B_I_OTHER_BALANCES"/>
|
||||||
|
<column name="label" value="III.b.i Balances with the banking system in all other accounts"/>
|
||||||
|
<column name="description" value="Form 1 Part III.b(i). Balances held with the banking system in accounts other than those in III.a."/>
|
||||||
|
<column name="role_group" value="RESERVE"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="511"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM1_III_B_II_MONEY_AT_CALL_BS"/>
|
||||||
|
<column name="label" value="III.b.ii Money at call and short notice with the banking system"/>
|
||||||
|
<column name="description" value="Form 1 Part III.b(ii). Funds lent to the banking system at call or short notice."/>
|
||||||
|
<column name="role_group" value="RESERVE"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="512"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM1_III_B_IV_ADVANCES_BS"/>
|
||||||
|
<column name="label" value="III.b.iv Advances and other assets with the banking system"/>
|
||||||
|
<column name="description" value="Form 1 Part III.b(iv). Advances to the banking system and any other assets held with it."/>
|
||||||
|
<column name="role_group" value="RESERVE"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="513"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0585-3-seed-form1-current-account-balances">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_role"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_VI_%'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<!--
|
||||||
|
Part VI: balances in CURRENT accounts, by counterparty. These are what the cash reserve
|
||||||
|
is actually maintained in, alongside cash on hand, so the form names each counterparty
|
||||||
|
separately rather than reporting one combined figure.
|
||||||
|
|
||||||
|
The Reserve Bank head duplicates nothing: an existing custom head of the same name was
|
||||||
|
added ad hoc, whereas this one carries the form's own item number so the return can find
|
||||||
|
it by position rather than by what somebody typed.
|
||||||
|
-->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM1_VI_A_RBI_CURRENT"/>
|
||||||
|
<column name="label" value="VI.a Balance in current account with the Reserve Bank of India"/>
|
||||||
|
<column name="description" value="Form 1 Part VI.a. Counts toward the cash reserve actually maintained (item X = V + VI + VIII)."/>
|
||||||
|
<column name="role_group" value="RESERVE"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="520"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM1_VI_B_STATE_COOP_CURRENT"/>
|
||||||
|
<column name="label" value="VI.b Balance in current account with the State Co-operative Bank"/>
|
||||||
|
<column name="description" value="Form 1 Part VI.b. Current account with the State Co-operative Bank of the state concerned. Counts toward the cash reserve actually maintained."/>
|
||||||
|
<column name="role_group" value="RESERVE"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="521"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM1_VI_C_DCCB_CURRENT"/>
|
||||||
|
<column name="label" value="VI.c Balance in current account with the District Central Co-operative Bank"/>
|
||||||
|
<column name="description" value="Form 1 Part VI.c. Current account with the District Central Co-operative Bank of the district concerned. Counts toward the cash reserve actually maintained."/>
|
||||||
|
<column name="role_group" value="RESERVE"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="522"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0585-4-seed-form1-other-balances">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_role"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM1_VII_%'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<!--
|
||||||
|
Part VII: balances of ALL OTHER types - term deposits with those banks, and government
|
||||||
|
securities. Kept apart from part VI because the two do different work: VI counts toward
|
||||||
|
the cash reserve, VII toward the liquidity requirement. Collapsing them would let a term
|
||||||
|
deposit satisfy the cash reserve, which it cannot.
|
||||||
|
-->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM1_VII_A_STATE_COOP_OTHER"/>
|
||||||
|
<column name="label" value="VII.a Balances of all other types with the State Co-operative Bank"/>
|
||||||
|
<column name="description" value="Form 1 Part VII.a. Deposits and other balances with the State Co-operative Bank, other than the current account in VI.b."/>
|
||||||
|
<column name="role_group" value="RESERVE"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="530"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM1_VII_B_DCCB_OTHER"/>
|
||||||
|
<column name="label" value="VII.b Balances of all other types with the District Central Co-operative Bank"/>
|
||||||
|
<column name="description" value="Form 1 Part VII.b. Deposits and other balances with the District Central Co-operative Bank, other than the current account in VI.c."/>
|
||||||
|
<column name="role_group" value="RESERVE"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="531"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM1_VII_C_GOVT_SECURITIES"/>
|
||||||
|
<column name="label" value="VII.c Investment in Government securities"/>
|
||||||
|
<column name="description" value="Form 1 Part VII.c. Government securities held. The principal component of the assets maintained against the liquidity requirement (item XII)."/>
|
||||||
|
<column name="role_group" value="RESERVE"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="532"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+908
@@ -0,0 +1,908 @@
|
|||||||
|
<?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.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Form IX - Return on Assets and Liabilities, return code R408.
|
||||||
|
|
||||||
|
A DIFFERENT return from Form 9 (SLR) despite the name. Form 9/SLR reports one ratio:
|
||||||
|
liabilities, the liquid assets held against them, and whether the bank complied. Form IX
|
||||||
|
reports the bank's WHOLE balance sheet - every class of liability and every class of asset -
|
||||||
|
and its two sides must agree. Filed monthly, as at the month end.
|
||||||
|
|
||||||
|
Reported in RUPEES THOUSANDS, not rupees. The conversion happens once, on the finished
|
||||||
|
figure, never per account: the filed return this was built from rounded each line
|
||||||
|
independently and came out with its two sides 2 thousand apart, then printed a
|
||||||
|
"DIFF ADJUSTED" line rather than saying so.
|
||||||
|
|
||||||
|
Every head here is seeded UNMAPPED, in role_group FORM9 so it sits under its own filter on
|
||||||
|
the configuration screen and cannot be confused with a Form 1 head. A head nobody maps
|
||||||
|
reports nil, which on this return is a real answer - a bank with no borrowings from the
|
||||||
|
EXIM Bank files that line empty.
|
||||||
|
|
||||||
|
role_usage on this return says which side of the balance sheet a head belongs to and how it
|
||||||
|
folds into the totals:
|
||||||
|
LIABILITY - a liability head; enters Total Liabilities
|
||||||
|
ASSET - an asset head; enters Total Assets
|
||||||
|
MEMO - stated on the face of the form but entering no total (the "of which" splits)
|
||||||
|
-->
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0586-0-widen-role-usage-for-asset">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<tableExists tableName="m_rbi_reserve_role"/>
|
||||||
|
</preConditions>
|
||||||
|
<!--
|
||||||
|
0575 constrained role_usage to four values and 0584 widened it to five. ASSET is a
|
||||||
|
sixth, and it has to be admitted BEFORE any row below can carry it - otherwise every
|
||||||
|
asset insert is rejected by the constraint rather than by anything meaningful, and the
|
||||||
|
migration aborts part-way with half the heads seeded.
|
||||||
|
|
||||||
|
ASSET is not ASSET_NET. That one is netted OFF the liability base to arrive at NDTL;
|
||||||
|
this one is added INTO Total Assets on a return that has no such base. Reusing ASSET_NET
|
||||||
|
here would have subtracted Form IX's assets from Form 1's liabilities.
|
||||||
|
|
||||||
|
Dropped and recreated rather than altered: a CHECK constraint has no in-place edit, and
|
||||||
|
recreating it keeps the guard rather than quietly leaving the column unconstrained.
|
||||||
|
-->
|
||||||
|
<sql>
|
||||||
|
ALTER TABLE m_rbi_reserve_role DROP CONSTRAINT IF EXISTS ck_rbi_reserve_role_usage;
|
||||||
|
ALTER TABLE m_rbi_reserve_role ADD CONSTRAINT ck_rbi_reserve_role_usage
|
||||||
|
CHECK (role_usage IN ('LIABILITY', 'ASSET_NET', 'MAINTAINED', 'SLR_ASSET', 'ASSET', 'MEMO'));
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- ══════════════════ A. LIABILITIES ══════════════════ -->
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0586-1-seed-form9-capital-and-reserves">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_role"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE role_group = 'FORM9'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
|
||||||
|
<!-- 1. Paid Up capital -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L1_A_CAPITAL_INDIVIDUALS"/>
|
||||||
|
<column name="label" value="1(a) Paid up capital - Individuals"/>
|
||||||
|
<column name="description" value="Share capital subscribed by individual members. In a co-operative bank this is usually the whole of the paid up capital, and it is the linking share and shareholder accounts together."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1010"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L1_B_CAPITAL_COOP_SOCIETIES"/>
|
||||||
|
<column name="label" value="1(b) Paid up capital - Co-operative Societies"/>
|
||||||
|
<column name="description" value="Share capital subscribed by co-operative societies."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1011"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L1_C_CAPITAL_STATE_GOVT"/>
|
||||||
|
<column name="label" value="1(c) Paid up capital - State Government"/>
|
||||||
|
<column name="description" value="Share capital subscribed by the State Government, including a co-operative bank holding on the State's behalf."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1012"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L1_D_CAPITAL_OTHERS"/>
|
||||||
|
<column name="label" value="1(d) Paid up capital - Others"/>
|
||||||
|
<column name="description" value="Share capital from any subscriber not covered by 1(a) to 1(c)."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1013"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 2. Reserve fund etc. -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L2_A_STATUTORY_RESERVE"/>
|
||||||
|
<column name="label" value="2(a) Statutory Reserves"/>
|
||||||
|
<column name="description" value="The reserve fund a co-operative bank is required by statute to build out of its profits."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1020"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L2_B_AGRI_CREDIT_FUND"/>
|
||||||
|
<column name="label" value="2(b) Agricultural Credit (Stabilisation) Fund"/>
|
||||||
|
<column name="description" value="Fund held against agricultural credit losses."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1021"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L2_C_DIVIDEND_EQUALISATION"/>
|
||||||
|
<column name="label" value="2(c) Dividend Equalisation Fund"/>
|
||||||
|
<column name="description" value="Fund set aside to even out dividend across years."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1022"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L2_D_SPECIAL_BAD_DEBT"/>
|
||||||
|
<column name="label" value="2(d) Special Bad Debt Reserve"/>
|
||||||
|
<column name="description" value="Reserve earmarked against specific bad debts, where the bank keeps one apart from its general bad and doubtful debt reserve."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1023"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L2_E_BAD_DOUBTFUL_RESERVE"/>
|
||||||
|
<column name="label" value="2(e) Bad and Doubtful Debt Reserve"/>
|
||||||
|
<column name="description" value="The bad and doubtful debt reserve TOGETHER WITH the provision held against non-performing assets. Both are mapped to this one head: a bank that maps only its bad-debt reserve here understates the line by the whole of its NPA provision, which on a filed return is the difference between a reserve that covers its advances and one that does not."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1024"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L2_F_INVESTMENT_DEPRECIATION"/>
|
||||||
|
<column name="label" value="2(f) Investment Depreciation Reserve"/>
|
||||||
|
<column name="description" value="Reserve held against depreciation in the value of the bank's investments."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1025"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L2_G_OTHER_FUNDS"/>
|
||||||
|
<column name="label" value="2(g) Other Funds and Reserves"/>
|
||||||
|
<column name="description" value="Every other fund the bank carries - building, staff welfare, development, charitable, computer, training, vehicle, contingent provision and the rest. Map each such account here: the return prints one row per account under its own name, which is the 'Other Funds and Reserves - Specify' sheet of the RBI workbook."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1026"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 3. Principal/Subsidiary State Partnership Fund -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L3_A_PARTNERSHIP_CCB"/>
|
||||||
|
<column name="label" value="3(a) State Partnership Fund - share capital of Central Co-operative Banks"/>
|
||||||
|
<column name="description" value="Principal or subsidiary State partnership fund held for the share capital of central co-operative banks."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1030"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L3_B_PARTNERSHIP_PACS"/>
|
||||||
|
<column name="label" value="3(b) State Partnership Fund - share capital of Primary Agricultural Credit Societies"/>
|
||||||
|
<column name="description" value="State partnership fund held for the share capital of primary agricultural credit societies."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1031"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L3_C_PARTNERSHIP_OTHER"/>
|
||||||
|
<column name="label" value="3(c) State Partnership Fund - share capital of Other Societies"/>
|
||||||
|
<column name="description" value="State partnership fund held for the share capital of any other society."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1032"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0586-2-seed-form9-demand-liabilities">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_role"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_L4_%'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
|
||||||
|
<!-- 4(a) Current deposits -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L4_A1_CURRENT_CCB"/>
|
||||||
|
<column name="label" value="4(a)(i) Current deposits from Central Co-operative Banks"/>
|
||||||
|
<column name="description" value="Current account deposits held for central co-operative banks."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1040"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L4_A2_CURRENT_PCB"/>
|
||||||
|
<column name="label" value="4(a)(ii) Current deposits from Primary Co-operative Banks"/>
|
||||||
|
<column name="description" value="Current account deposits held for primary co-operative banks."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1041"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L4_A3_CURRENT_SOCIETIES"/>
|
||||||
|
<column name="label" value="4(a)(iii) Current deposits from Other Societies"/>
|
||||||
|
<column name="description" value="Current account deposits held for societies other than co-operative banks."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1042"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L4_A4_CURRENT_INDIVIDUALS"/>
|
||||||
|
<column name="label" value="4(a)(iv) Current deposits from Individuals, Firms, Associations etc."/>
|
||||||
|
<column name="description" value="Current account deposits of ordinary customers. In most urban co-operative banks this is the whole of the current deposit line."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1043"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L4_A5_CURRENT_RESERVE_FUND"/>
|
||||||
|
<column name="label" value="4(a)(v) Current deposits - Reserve fund deposits of societies in the bank's area"/>
|
||||||
|
<column name="description" value="Reserve fund deposits that societies within the bank's area of operation are required to keep with it."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1044"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 4(b) Savings deposits - DEMAND portion -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L4_B4_SAVINGS_DEMAND_INDIVIDUALS"/>
|
||||||
|
<column name="label" value="4(b)(iv) Savings deposits (demand portion) from Individuals, Firms, Associations etc."/>
|
||||||
|
<column name="description" value="The DEMAND share of ordinary savings balances. Map the savings account here with the demand percentage as its portion, and the same account under 5(b)(iv) with the remainder: the two shares together must be the whole balance. The split is the bank's own, settled with its auditors, and is NOT the split used on Form 1 - that return charges its ratios on a differently drawn base."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1048"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L4_B_SAVINGS_DEMAND_OTHER"/>
|
||||||
|
<column name="label" value="4(b)(i-iii,v) Savings deposits (demand portion) from Co-operative Banks and Societies"/>
|
||||||
|
<column name="description" value="The demand share of savings balances held for central co-operative banks, primary co-operative banks, other societies, and societies' reserve fund deposits."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1049"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 4(c) Borrowings from banks - demand -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L4_C_BORROWINGS_DEMAND"/>
|
||||||
|
<column name="label" value="4(c) Borrowings from banks (demand)"/>
|
||||||
|
<column name="description" value="Demand borrowings from the Reserve Bank, State Bank, Development Bank, National Bank, EXIM Bank, National Co-operative Development Corporation, State Co-operative Bank, Central Co-operative Bank or any other lender. Map each borrowing account here; the return prints one row per account under its lender's name."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1050"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 4(d) Other demand liabilities -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L4_D_OTHER_DEMAND"/>
|
||||||
|
<column name="label" value="4(d) Other Demand Liabilities"/>
|
||||||
|
<column name="description" value="Everything else the bank owes on demand - dividend payable, share capital payable, sundry creditors, suspense, staff dues, tax deducted at source, GST payable, credit balances in loan accounts, bills payable, provisions for income tax and audit fees. Map each such account here; the return prints one row per account, which is the 'Other Demand Liabilities - Specify' sheet of the RBI workbook."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1051"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0586-3-seed-form9-time-liabilities">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_role"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_L5_%'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
|
||||||
|
<!-- 5(a) Fixed deposits -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L5_A1_FD_CCB"/>
|
||||||
|
<column name="label" value="5(a)(i) Fixed deposits from Central Co-operative Banks"/>
|
||||||
|
<column name="description" value="Fixed deposits held for central co-operative banks."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1060"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L5_A2_FD_PCB"/>
|
||||||
|
<column name="label" value="5(a)(ii) Fixed deposits from Primary Co-operative Banks"/>
|
||||||
|
<column name="description" value="Fixed deposits held for primary co-operative banks."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1061"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L5_A3_FD_SOCIETIES"/>
|
||||||
|
<column name="label" value="5(a)(iii) Fixed deposits from Other Societies"/>
|
||||||
|
<column name="description" value="Fixed deposits held for societies other than co-operative banks."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1062"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L5_A4_FD_INDIVIDUALS"/>
|
||||||
|
<column name="label" value="5(a)(iv) Fixed deposits from Individuals, Firms, Associations etc."/>
|
||||||
|
<column name="description" value="Fixed deposits of ordinary customers, whatever the scheme they were taken under - single, double, or any named deposit product that is a term deposit."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1063"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L5_A5_FD_RESERVE_FUND"/>
|
||||||
|
<column name="label" value="5(a)(v) Fixed deposits - Reserve fund deposits of societies in the bank's area"/>
|
||||||
|
<column name="description" value="Reserve fund deposits of societies within the bank's area of operation, held as term deposits."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1064"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- Of which: the maturity split the form asks for on 5(a) -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L5_A_FD_UPTO_1YR"/>
|
||||||
|
<column name="label" value="Of 5(a): Fixed deposits up to 1 year"/>
|
||||||
|
<column name="description" value="The share of fixed deposits maturing within a year. Stated on the face of the form and entering no total - it is a split of 5(a), not an addition to it."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="1065"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L5_A_FD_ABOVE_1YR"/>
|
||||||
|
<column name="label" value="Of 5(a): Fixed deposits above 1 year"/>
|
||||||
|
<column name="description" value="The share of fixed deposits maturing after a year. A split of 5(a), entering no total."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="1066"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 5(b) Savings deposits - TIME portion -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L5_B4_SAVINGS_TIME_INDIVIDUALS"/>
|
||||||
|
<column name="label" value="5(b)(iv) Savings deposits (time portion) from Individuals, Firms, Associations etc."/>
|
||||||
|
<column name="description" value="The TIME share of ordinary savings balances - the remainder of the account after the demand share taken at 4(b)(iv). The two together must be the whole balance, or the return reports less in savings than the bank holds."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1070"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L5_B_SAVINGS_TIME_OTHER"/>
|
||||||
|
<column name="label" value="5(b)(i-iii,v) Savings deposits (time portion) from Co-operative Banks and Societies"/>
|
||||||
|
<column name="description" value="The time share of savings balances held for central co-operative banks, primary co-operative banks, other societies, and societies' reserve fund deposits."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1071"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 5(c) Borrowings from banks - time -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L5_C_BORROWINGS_TIME"/>
|
||||||
|
<column name="label" value="5(c) Borrowings from banks (time)"/>
|
||||||
|
<column name="description" value="Term borrowings from the Reserve Bank, State Bank, Development Bank, National Bank, EXIM Bank, National Co-operative Development Corporation, State Co-operative Bank, Central Co-operative Bank or any other lender."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1072"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 5(d) Cash certificates, recurring deposits -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L5_D_CASH_CERT_RD"/>
|
||||||
|
<column name="label" value="5(d) Cash Certificates, Recurring Deposits etc."/>
|
||||||
|
<column name="description" value="Recurring deposits, cash certificates and the bank's named small-savings schemes - mini deposits, monthly income schemes and the like. Every term product that is not a fixed deposit belongs here."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1073"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 5(e) Other time liabilities -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L5_E_OTHER_TIME"/>
|
||||||
|
<column name="label" value="5(e) Other Time Liabilities"/>
|
||||||
|
<column name="description" value="Time liabilities not covered above, chiefly interest accrued on deposits but not yet paid. Map each such account here; the return prints one row per account, which is the 'Other Time Liabilities - Specify' sheet of the RBI workbook."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1074"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 6-9 -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L6_BRANCH_ADJUSTMENTS"/>
|
||||||
|
<column name="label" value="6. Branch Adjustments (liabilities)"/>
|
||||||
|
<column name="description" value="Net inter-branch balances standing to the credit side at the month end."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1080"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L7_OVERDUE_INTEREST_RESERVE"/>
|
||||||
|
<column name="label" value="7. Overdue Interest Reserve"/>
|
||||||
|
<column name="description" value="Interest charged on overdue advances and held in reserve rather than taken to income. It has a matching entry in Interest Receivable on the asset side, so leaving one mapped and the other not breaks the balance by exactly this figure."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1081"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_L9_BALANCE_OF_PROFIT"/>
|
||||||
|
<column name="label" value="9. Balance of Profit"/>
|
||||||
|
<column name="description" value="Profit for the year carried on the balance sheet. A loss is reported on the asset side at item 12 instead, never as a negative here."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="LIABILITY"/>
|
||||||
|
<column name="position" valueNumeric="1082"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- ══════════════════ B. ASSETS ══════════════════ -->
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0586-4-seed-form9-cash-and-banks">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_role"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A%'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A1_CASH_IN_HAND"/>
|
||||||
|
<column name="label" value="1. Cash in Hand"/>
|
||||||
|
<column name="description" value="Cash the bank itself holds - vault and teller balances at the month end."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2010"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 2. Balance with banks - CURRENT -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A2_A_CUR_RBI"/>
|
||||||
|
<column name="label" value="2(a) Balance with the Reserve Bank of India (current)"/>
|
||||||
|
<column name="description" value="Current account balance with the Reserve Bank of India."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2020"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A2_B_CUR_SBI"/>
|
||||||
|
<column name="label" value="2(b) Balance with the State Bank of India and its subsidiaries (current)"/>
|
||||||
|
<column name="description" value="Current account balances with the State Bank of India, its subsidiary banks and the corresponding new banks."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2021"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A2_C_CUR_BANKING_SYSTEM"/>
|
||||||
|
<column name="label" value="2(c) Balance with the Banking System (current)"/>
|
||||||
|
<column name="description" value="Current account balances with commercial banks - the private and public sector banks the bank keeps operating, clearing, ATM, UPI, IMPS and RTGS accounts with. Not co-operative banks, which have their own lines below."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2022"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A2_D_CUR_STATE_COOP"/>
|
||||||
|
<column name="label" value="2(d) Balance with the State Co-operative Bank of the State concerned (current)"/>
|
||||||
|
<column name="description" value="Current account balance with the apex co-operative bank of the State."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2023"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A2_E_CUR_DCCB"/>
|
||||||
|
<column name="label" value="2(e) Balance with the Central Co-operative Bank of the District concerned (current)"/>
|
||||||
|
<column name="description" value="Current account balance with the district central co-operative bank."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2024"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A2_F_CUR_OTHER_COOP"/>
|
||||||
|
<column name="label" value="2(f) Balance with other Co-operative Banks (current)"/>
|
||||||
|
<column name="description" value="Current account balances with co-operative banks other than the State and district central co-operative banks."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2025"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 2. Balance with banks - SAVINGS -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A2_SAV_BANKING_SYSTEM"/>
|
||||||
|
<column name="label" value="2. Balance with the Banking System (savings)"/>
|
||||||
|
<column name="description" value="Savings account balances the bank holds with commercial banks. The form reports current and savings in separate columns, so a savings balance mapped to the current head would be shown in the wrong one."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2030"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A2_SAV_COOP"/>
|
||||||
|
<column name="label" value="2. Balance with Co-operative Banks (savings)"/>
|
||||||
|
<column name="description" value="Savings account balances with the State co-operative bank, the district central co-operative bank, or any other co-operative bank."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2031"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 3. Money at call and short notice -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A3_MONEY_AT_CALL"/>
|
||||||
|
<column name="label" value="3. Money at Call and Short Notice"/>
|
||||||
|
<column name="description" value="Money lent at call or short notice to the State Bank and its subsidiaries, the banking system, the State co-operative bank, the district central co-operative bank or other co-operative banks. Ordinary fixed deposits with banks do NOT belong here - they are investments at 4(g)."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2040"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0586-5-seed-form9-investments">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_role"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A4_%'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A4_A1_CENTRAL_GOVT_SEC"/>
|
||||||
|
<column name="label" value="4(a)(i) Investments in Central Government Securities"/>
|
||||||
|
<column name="description" value="Central Government securities, including treasury bills."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2050"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A4_A2_STATE_GOVT_SEC"/>
|
||||||
|
<column name="label" value="4(a)(ii) Investments in State Government Securities"/>
|
||||||
|
<column name="description" value="State Government securities. Securities held in the available-for-sale book belong here too unless the bank classifies them elsewhere."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2051"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A4_B_LAND_MORTGAGE_DEBENTURES"/>
|
||||||
|
<column name="label" value="4(b) Debentures of Land Mortgage Banks"/>
|
||||||
|
<column name="description" value="Debentures issued by land development or land mortgage banks."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2052"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A4_C_OTHER_TRUSTEE_SEC"/>
|
||||||
|
<column name="label" value="4(c) Other Trustee Securities"/>
|
||||||
|
<column name="description" value="Trustee securities other than Government securities and land mortgage bank debentures."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2053"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A4_D_SHARES_STATE_COOP"/>
|
||||||
|
<column name="label" value="4(d) Shares of the State Co-operative Banks"/>
|
||||||
|
<column name="description" value="Shareholding in the apex co-operative bank of the State."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2054"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A4_E_SHARES_DCCB"/>
|
||||||
|
<column name="label" value="4(e) Shares of the Central Co-operative Banks"/>
|
||||||
|
<column name="description" value="Shareholding in the district central co-operative bank."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2055"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A4_F_SHARES_OTHER_COOP"/>
|
||||||
|
<column name="label" value="4(f) Shares of other Co-operative Institutions"/>
|
||||||
|
<column name="description" value="Shareholding in co-operative institutions other than the State and district central co-operative banks."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2056"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A4_G1_FD_SBI"/>
|
||||||
|
<column name="label" value="4(g)(i) Fixed deposits with the State Bank of India and its subsidiaries"/>
|
||||||
|
<column name="description" value="Term deposits placed with the State Bank of India and its subsidiary banks."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2057"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A4_G2_FD_BANKING_SYSTEM"/>
|
||||||
|
<column name="label" value="4(g)(ii) Fixed deposits with the Banking System (companies)"/>
|
||||||
|
<column name="description" value="Term deposits and investments placed with commercial banks."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2058"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A4_G3_FD_STATE_COOP"/>
|
||||||
|
<column name="label" value="4(g)(iii) Fixed deposits with the State Co-operative Bank of the State concerned"/>
|
||||||
|
<column name="description" value="Term deposits and investments placed with the apex co-operative bank of the State."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2059"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A4_G4_FD_DCCB"/>
|
||||||
|
<column name="label" value="4(g)(iv) Fixed deposits with the Central Co-operative Bank of the district concerned"/>
|
||||||
|
<column name="description" value="Term deposits and investments placed with the district central co-operative bank."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2060"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A4_G5_FD_OTHER_COOP"/>
|
||||||
|
<column name="label" value="4(g)(v) Fixed deposits with other Co-operative Banks"/>
|
||||||
|
<column name="description" value="Term deposits placed with co-operative banks other than the State and district central co-operative banks."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2061"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A4_H_OTHER_INVESTMENTS"/>
|
||||||
|
<column name="label" value="4(h) Other Investments"/>
|
||||||
|
<column name="description" value="Investments outside every class above - SIDBI and other development-institution deposits, and non-SLR investments. Map each such account here; the return prints one row per account, which is the 'Others Investments - Specify' sheet of the RBI workbook."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2062"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 5. Investments out of the State Partnership Fund -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A5_PARTNERSHIP_INVESTMENTS"/>
|
||||||
|
<column name="label" value="5. Investments out of the Principal/Subsidiary State Partnership Fund"/>
|
||||||
|
<column name="description" value="Shares of central co-operative banks, primary agricultural credit societies or other societies, bought out of the State partnership fund reported at liability 3."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2070"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0586-6-seed-form9-advances-and-other-assets">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_role"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_role WHERE config_key LIKE 'FORM9_A6_%'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
|
||||||
|
<!-- 6. Loans and advances -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A6_A_SEASONAL_AGRI"/>
|
||||||
|
<column name="label" value="6(a) Loans for Seasonal Agricultural Operations"/>
|
||||||
|
<column name="description" value="Advances for seasonal agricultural operations."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2080"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A6_B_MARKETING_CROPS"/>
|
||||||
|
<column name="label" value="6(b) Loans for Marketing of Crops"/>
|
||||||
|
<column name="description" value="Advances for the marketing of crops, secured or unsecured."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2081"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A6_C_MEDIUM_TERM_AGRI"/>
|
||||||
|
<column name="label" value="6(c) Loans for Medium Term Agricultural purposes"/>
|
||||||
|
<column name="description" value="Medium term advances for agricultural purposes."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2082"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A6_D_WEAVERS_SOCIETIES"/>
|
||||||
|
<column name="label" value="6(d) Loans for Weavers Societies"/>
|
||||||
|
<column name="description" value="Advances to weavers societies."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2083"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A6_E_INDUSTRIAL_SHORT"/>
|
||||||
|
<column name="label" value="6(e)(i) Loans for other Industrial purposes - Short Term"/>
|
||||||
|
<column name="description" value="Short term advances for industrial purposes. Map secured and unsecured accounts separately using the liability class on each mapping row, so the form can print its Secured and Unsecured columns."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2084"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A6_E_INDUSTRIAL_MEDIUM"/>
|
||||||
|
<column name="label" value="6(e)(ii) Loans for other Industrial purposes - Medium Term"/>
|
||||||
|
<column name="description" value="Medium term advances for industrial purposes."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2085"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A6_F_OTHER_SHORT"/>
|
||||||
|
<column name="label" value="6(f)(i) Loans for Other purposes - Short Term"/>
|
||||||
|
<column name="description" value="Short term advances for purposes other than agriculture and industry - cash credits, overdrafts, loans against deposits and against security, warehouse receipt finance. For an urban co-operative bank this is usually the largest line on the return."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2086"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A6_F_OTHER_MEDIUM"/>
|
||||||
|
<column name="label" value="6(f)(ii) Loans for Other purposes - Medium Term"/>
|
||||||
|
<column name="description" value="Medium term advances for purposes other than agriculture and industry."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2087"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A6_F_OTHER_LONG"/>
|
||||||
|
<column name="label" value="6(f)(iii) Loans for Other purposes - Long Term"/>
|
||||||
|
<column name="description" value="Long term advances for purposes other than agriculture and industry - term loans and property mortgage limits."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2088"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 6(g) Explanation lines - stated, entering no total -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A6_G_OVERDUE_ADVANCES"/>
|
||||||
|
<column name="label" value="6(g)(i) Of the above advances, those which are overdue"/>
|
||||||
|
<column name="description" value="Advances standing overdue at the month end. Stated on the face of the form as an explanation of item 6 and entering no total - it is part of the advances already counted, not an addition to them. The form's overdue percentage is computed from it."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="2089"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A6_G_PRIORITY_SECTOR"/>
|
||||||
|
<column name="label" value="6(g)(ii) Advances outstanding in respect of the Priority Sector"/>
|
||||||
|
<column name="description" value="Advances to the priority sector. An explanation of item 6, entering no total; the form's priority-sector percentage is computed from it."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="MEMO"/>
|
||||||
|
<column name="position" valueNumeric="2090"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 7-12 -->
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A7_BILLS_PURCHASED"/>
|
||||||
|
<column name="label" value="7. Bills Purchased and Discounted"/>
|
||||||
|
<column name="description" value="Bills the bank has purchased or discounted and not yet realised."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2100"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A8_INTEREST_RECEIVABLE"/>
|
||||||
|
<column name="label" value="8. Interest Receivable (of which overdue)"/>
|
||||||
|
<column name="description" value="Interest earned but not yet received. It matches the Overdue Interest Reserve on the liability side, so mapping one without the other breaks the balance by exactly this figure."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2101"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A9_FIXED_ASSETS"/>
|
||||||
|
<column name="label" value="9. Premises, Furniture, Fixtures and Other Fixed Assets"/>
|
||||||
|
<column name="description" value="Land and buildings, furniture and fixtures, computer hardware and software, plant and machinery, electrical installations and security systems - at their written down value."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2102"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A10_BRANCH_ADJUSTMENTS"/>
|
||||||
|
<column name="label" value="10. Branch Adjustments (assets)"/>
|
||||||
|
<column name="description" value="Net inter-branch balances standing to the debit side at the month end."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2103"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A11_I_TANGIBLE_ASSETS"/>
|
||||||
|
<column name="label" value="11(i) Other Assets - Tangible"/>
|
||||||
|
<column name="description" value="Tangible assets outside every class above - advances to employees, stationery and printing stock, deposits with utilities, accrued interest on investments."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2104"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A11_II_INTANGIBLE_ASSETS"/>
|
||||||
|
<column name="label" value="11(ii) Other Assets - Intangible"/>
|
||||||
|
<column name="description" value="Intangible assets - deferred revenue expenditure, goodwill and the like."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2105"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_rbi_reserve_role">
|
||||||
|
<column name="config_key" value="FORM9_A12_BALANCE_OF_LOSS"/>
|
||||||
|
<column name="label" value="12. Balance of Loss"/>
|
||||||
|
<column name="description" value="Accumulated loss carried on the balance sheet. A profit is reported on the liability side at item 9 instead, never as a negative here."/>
|
||||||
|
<column name="role_group" value="FORM9"/>
|
||||||
|
<column name="role_usage" value="ASSET"/>
|
||||||
|
<column name="position" valueNumeric="2106"/>
|
||||||
|
<column name="created_on" valueComputed="now()"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+212
@@ -0,0 +1,212 @@
|
|||||||
|
<?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.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
A starting set of Form IX mappings, matched by GL CODE against whatever the tenant's chart
|
||||||
|
actually holds.
|
||||||
|
|
||||||
|
Every insert is a SELECT: an account whose code is not in this chart contributes no row, so
|
||||||
|
a tenant with a different chart gets the heads it has and nothing invented for the ones it
|
||||||
|
does not. Nothing here is hardcoded into the return itself - these are ordinary rows in the
|
||||||
|
same configuration table the Admin screen writes, and a bank that disagrees with any of them
|
||||||
|
changes it there.
|
||||||
|
|
||||||
|
Deliberately a starting point and not a complete mapping. Heads with no counterpart in this
|
||||||
|
chart - the State Co-operative Bank, the district central co-operative bank, government
|
||||||
|
securities, the State partnership fund - are left unmapped and report nil, which on this
|
||||||
|
return is a real answer rather than a gap.
|
||||||
|
|
||||||
|
Guarded on the head having NO mapping yet, so a bank that has already mapped a head by hand
|
||||||
|
keeps its own choice. Re-running this never overwrites a decision somebody made.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0587-1-map-form9-capital-and-reserves">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_gl_config"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_%'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<sql>
|
||||||
|
-- 1(a) Paid up capital - Individuals. Both share accounts: in a co-operative bank the
|
||||||
|
-- linking share and the ordinary share together ARE the members' paid up capital.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_L1_A_CAPITAL_INDIVIDUALS', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code IN ('300101', '300102');
|
||||||
|
|
||||||
|
-- 2(a) Statutory Reserves.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_L2_A_STATUTORY_RESERVE', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code = '300902';
|
||||||
|
|
||||||
|
-- 2(c) Dividend Equalisation Fund.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_L2_C_DIVIDEND_EQUALISATION', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code = '300901';
|
||||||
|
|
||||||
|
-- 2(e) Bad and Doubtful Debt Reserve. The NPA provision AND the standard-asset
|
||||||
|
-- provision, because the form's line is the whole reserve held against advances - not
|
||||||
|
-- the bad-debt account alone. On the filed return this was drawn from, mapping only one
|
||||||
|
-- of them would have understated the line by three quarters.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_L2_E_BAD_DOUBTFUL_RESERVE', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code IN ('500501', '500502');
|
||||||
|
|
||||||
|
-- 2(g) Other Funds and Reserves - every other fund the bank carries.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_L2_G_OTHER_FUNDS', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code = '200905';
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0587-2-map-form9-demand-liabilities">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_gl_config"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_L4_%'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<sql>
|
||||||
|
-- 4(a)(iv) Current deposits from individuals, firms and associations.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_L4_A4_CURRENT_INDIVIDUALS', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code = '200102';
|
||||||
|
|
||||||
|
-- 4(b)(iv) Savings deposits, DEMAND share.
|
||||||
|
--
|
||||||
|
-- Split 11/89 against 5(b)(iv), which is the split the filed return this was drawn from
|
||||||
|
-- carries. It is NOT the 20/80 Form 1 uses: that return charges its ratios on a
|
||||||
|
-- differently drawn base, and the two splits are settled separately with the bank's
|
||||||
|
-- auditors. The two shares here must always total 100, or the return reports less in
|
||||||
|
-- savings than the bank holds.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_L4_B4_SAVINGS_DEMAND_INDIVIDUALS', id, 11, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code = '200101';
|
||||||
|
|
||||||
|
-- 4(d) Other Demand Liabilities - everything owed on demand that is not a deposit.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_L4_D_OTHER_DEMAND', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code IN ('200203', '200204', '200401', '200402', '200501', '200502', '200503',
|
||||||
|
'200504', '200505', '200506', '200901', '200902', '200903', '200904',
|
||||||
|
'898966');
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0587-3-map-form9-time-liabilities">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_gl_config"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_L5_%'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<sql>
|
||||||
|
-- 5(a)(iv) Fixed deposits from individuals, firms and associations.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_L5_A4_FD_INDIVIDUALS', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code = '200103';
|
||||||
|
|
||||||
|
-- 5(b)(iv) Savings deposits, TIME share - the remainder of the account.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_L5_B4_SAVINGS_TIME_INDIVIDUALS', id, 89, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code = '200101';
|
||||||
|
|
||||||
|
-- 5(d) Cash certificates and recurring deposits.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_L5_D_CASH_CERT_RD', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code = '200201';
|
||||||
|
|
||||||
|
-- 5(e) Other Time Liabilities - interest accrued on deposits but not yet paid.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_L5_E_OTHER_TIME', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code IN ('200202', '200301', '200302', '200303');
|
||||||
|
|
||||||
|
-- 9. Balance of Profit.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_L9_BALANCE_OF_PROFIT', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code IN ('300903', 'BANKPROFIT01');
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0587-4-map-form9-assets">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_gl_config"/>
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_gl_config WHERE config_key LIKE 'FORM9_A%'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<sql>
|
||||||
|
-- 1. Cash in Hand - vault and teller balances.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_A1_CASH_IN_HAND', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code IN ('100101', '100102');
|
||||||
|
|
||||||
|
-- 2(a) Balance with the Reserve Bank of India.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_A2_A_CUR_RBI', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code = '100203';
|
||||||
|
|
||||||
|
-- 2(c) Balance with the Banking System - the commercial banks the bank keeps operating
|
||||||
|
-- accounts with. Co-operative banks have their own lines, which this chart has no
|
||||||
|
-- accounts for.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_A2_C_CUR_BANKING_SYSTEM', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code IN ('100201', '100202');
|
||||||
|
|
||||||
|
-- 6(f)(i) Short term advances for other purposes - cash credit, loans against deposits
|
||||||
|
-- and warehouse receipt finance.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_A6_F_OTHER_SHORT', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code IN ('100301', '100302', '100303');
|
||||||
|
|
||||||
|
-- 6(f)(ii) Medium term advances - personal loans.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_A6_F_OTHER_MEDIUM', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code = '100402';
|
||||||
|
|
||||||
|
-- 6(f)(iii) Long term advances - loans against property and house loans.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_A6_F_OTHER_LONG', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code IN ('100401', '100501');
|
||||||
|
|
||||||
|
-- 8. Interest Receivable.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_A8_INTEREST_RECEIVABLE', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code = '100601';
|
||||||
|
|
||||||
|
-- 11(i) Other Assets, tangible - advance tax and tax deducted at source recoverable.
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_A11_I_TANGIBLE_ASSETS', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code = '100602';
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
<?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.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The dividend round-off account, which 0587 left out.
|
||||||
|
|
||||||
|
It holds the paise dropped when a dividend is apportioned across members - a real reserve of
|
||||||
|
the bank's, small but on the balance sheet, and every rupee in it is a rupee the two sides of
|
||||||
|
Form IX disagree by while nothing reports it.
|
||||||
|
|
||||||
|
A separate changeset rather than an edit to 0587: that one has already run, and Liquibase
|
||||||
|
checksums a changeset by its contents. Editing it in place would change the checksum of a
|
||||||
|
changeset the database records as applied, and startup aborts rather than guess which version
|
||||||
|
was meant.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0588-1-map-dividend-round-off">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_rbi_reserve_gl_config"/>
|
||||||
|
<!-- Only where nobody has mapped it by hand since: a bank that put it somewhere
|
||||||
|
else made that choice deliberately and keeps it. -->
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_rbi_reserve_gl_config c
|
||||||
|
JOIN acc_gl_account a ON a.id = c.gl_account_id
|
||||||
|
WHERE a.gl_code = 'DIVROUNDOFF01'
|
||||||
|
</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_rbi_reserve_gl_config (config_key, gl_account_id, weight_percentage, created_on)
|
||||||
|
SELECT 'FORM9_L2_G_OTHER_FUNDS', id, 100, now() FROM acc_gl_account
|
||||||
|
WHERE gl_code = 'DIVROUNDOFF01';
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!--
|
||||||
|
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements. See the NOTICE file
|
||||||
|
distributed with this work for additional information
|
||||||
|
regarding copyright ownership. The ASF licenses this file
|
||||||
|
to you under the Apache License, Version 2.0 (the
|
||||||
|
"License"); you may not use this file except in compliance
|
||||||
|
with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing,
|
||||||
|
software distributed under the License is distributed on an
|
||||||
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations
|
||||||
|
under the License.
|
||||||
|
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Savings interest is no longer posted straight off the ledger. On a product's posting date a
|
||||||
|
report is generated at noon listing what each account will be paid; an authorised user
|
||||||
|
approves it; and only the approved figures are posted, once the last branch closes the day.
|
||||||
|
|
||||||
|
The report is kept, not recomputed, because the approved list is the authority: the posting
|
||||||
|
pays exactly what was approved, account by account. Regenerating supersedes the previous
|
||||||
|
report rather than overwriting it, so what was shown and what was approved stays on record.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="0589-1-savings-interest-posting-report">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><tableExists tableName="m_savings_interest_posting_report"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<createTable tableName="m_savings_interest_posting_report">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="savings_product_id" type="BIGINT">
|
||||||
|
<constraints nullable="false" foreignKeyName="fk_sipr_product" referencedTableName="m_savings_product"
|
||||||
|
referencedColumnNames="id"/>
|
||||||
|
</column>
|
||||||
|
<column name="posting_date" type="DATE">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<!-- GENERATED, APPROVED, POSTED, or SUPERSEDED by a regeneration. -->
|
||||||
|
<column name="status" type="VARCHAR(20)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="total_interest" type="DECIMAL(19,6)" defaultValueNumeric="0">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="account_count" type="INT" defaultValueNumeric="0">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<!-- As the product mapped them when the report was generated, for display and checking. -->
|
||||||
|
<column name="payable_gl_account_id" type="BIGINT"/>
|
||||||
|
<column name="paid_gl_account_id" type="BIGINT"/>
|
||||||
|
<!-- AUTO from the noon job, MANUAL from a regeneration. -->
|
||||||
|
<column name="generation_mode" type="VARCHAR(10)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="generated_by" type="BIGINT"/>
|
||||||
|
<column name="generated_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="approved_by" type="BIGINT"/>
|
||||||
|
<column name="approved_at" type="TIMESTAMP"/>
|
||||||
|
<column name="posted_at" type="TIMESTAMP"/>
|
||||||
|
<column name="posting_run_id" type="BIGINT"/>
|
||||||
|
<!-- Why an approved report could not be posted; the nightly run retries it. -->
|
||||||
|
<column name="failure_reason" type="VARCHAR(1000)"/>
|
||||||
|
</createTable>
|
||||||
|
<createIndex tableName="m_savings_interest_posting_report" indexName="idx_sipr_product_date">
|
||||||
|
<column name="savings_product_id"/>
|
||||||
|
<column name="posting_date"/>
|
||||||
|
</createIndex>
|
||||||
|
<createIndex tableName="m_savings_interest_posting_report" indexName="idx_sipr_status_date">
|
||||||
|
<column name="status"/>
|
||||||
|
<column name="posting_date"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0589-2-savings-interest-posting-report-line">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><tableExists tableName="m_savings_interest_posting_report_line"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<createTable tableName="m_savings_interest_posting_report_line">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="report_id" type="BIGINT">
|
||||||
|
<constraints nullable="false" foreignKeyName="fk_siprl_report"
|
||||||
|
referencedTableName="m_savings_interest_posting_report" referencedColumnNames="id"/>
|
||||||
|
</column>
|
||||||
|
<column name="savings_account_id" type="BIGINT">
|
||||||
|
<constraints nullable="false" foreignKeyName="fk_siprl_account" referencedTableName="m_savings_account"
|
||||||
|
referencedColumnNames="id"/>
|
||||||
|
</column>
|
||||||
|
<column name="account_no" type="VARCHAR(50)"/>
|
||||||
|
<column name="client_name" type="VARCHAR(300)"/>
|
||||||
|
<column name="office_id" type="BIGINT"/>
|
||||||
|
<column name="interest_amount" type="DECIMAL(19,6)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="balance_before" type="DECIMAL(19,6)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="balance_after" type="DECIMAL(19,6)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<!-- The m_savings_daily_interest row the interest was read from, updated when posted. -->
|
||||||
|
<column name="ledger_id" type="BIGINT"/>
|
||||||
|
<column name="savings_transaction_id" type="BIGINT"/>
|
||||||
|
</createTable>
|
||||||
|
<createIndex tableName="m_savings_interest_posting_report_line" indexName="idx_siprl_report">
|
||||||
|
<column name="report_id"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0589-3-savings-interest-posting-report-job">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'Generate Savings Interest Posting Report'</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="job">
|
||||||
|
<column name="name" value="Generate Savings Interest Posting Report"/>
|
||||||
|
<column name="display_name" value="Generate Savings Interest Posting Report"/>
|
||||||
|
<column name="short_name" value="SA_IPRPT"/>
|
||||||
|
<column name="cron_expression" value="0 0 12 1/1 * ? *"/>
|
||||||
|
<column name="create_time" valueDate="${current_datetime}"/>
|
||||||
|
<column name="task_priority" valueNumeric="5"/>
|
||||||
|
<column name="group_name"/>
|
||||||
|
<column name="previous_run_start_time"/>
|
||||||
|
<column name="job_key" value="Generate Savings Interest Posting ReportJobDetail1 _ DEFAULT"/>
|
||||||
|
<column name="initializing_errorlog"/>
|
||||||
|
<column name="is_active" valueBoolean="true"/>
|
||||||
|
<column name="currently_running" valueBoolean="false"/>
|
||||||
|
<column name="updates_allowed" valueBoolean="true"/>
|
||||||
|
<column name="scheduler_group" valueNumeric="1"/>
|
||||||
|
<column name="is_misfired" valueBoolean="false"/>
|
||||||
|
<column name="node_id" valueNumeric="1"/>
|
||||||
|
<column name="is_mismatched_job" valueBoolean="true"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0589-4-savings-interest-posting-report-permissions">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_SAVINGSINTERESTREPORT',
|
||||||
|
'REGENERATE_SAVINGSINTERESTREPORT', 'APPROVE_SAVINGSINTERESTREPORT')
|
||||||
|
</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="READ_SAVINGSINTERESTREPORT"/>
|
||||||
|
<column name="entity_name" value="SAVINGSINTERESTREPORT"/>
|
||||||
|
<column name="action_name" value="READ"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="REGENERATE_SAVINGSINTERESTREPORT"/>
|
||||||
|
<column name="entity_name" value="SAVINGSINTERESTREPORT"/>
|
||||||
|
<column name="action_name" value="REGENERATE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="APPROVE_SAVINGSINTERESTREPORT"/>
|
||||||
|
<column name="entity_name" value="SAVINGSINTERESTREPORT"/>
|
||||||
|
<column name="action_name" value="APPROVE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0589-5-grant-savings-interest-posting-report-permissions">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_role_permission rp
|
||||||
|
JOIN m_permission p ON p.id = rp.permission_id
|
||||||
|
WHERE p.code IN ('READ_SAVINGSINTERESTREPORT', 'REGENERATE_SAVINGSINTERESTREPORT',
|
||||||
|
'APPROVE_SAVINGSINTERESTREPORT')
|
||||||
|
</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<!-- Super user and manager-named roles, as 0581 grants its own. -->
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_role_permission (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM m_role r
|
||||||
|
CROSS JOIN m_permission p
|
||||||
|
WHERE p.code IN ('READ_SAVINGSINTERESTREPORT', 'REGENERATE_SAVINGSINTERESTREPORT',
|
||||||
|
'APPROVE_SAVINGSINTERESTREPORT')
|
||||||
|
AND (LOWER(r.name) LIKE '%manager%' OR LOWER(r.name) = 'super user')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM m_role_permission rp
|
||||||
|
WHERE rp.role_id = r.id AND rp.permission_id = p.id
|
||||||
|
)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
</databaseChangeLog>
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!--
|
||||||
|
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements. See the NOTICE file
|
||||||
|
distributed with this work for additional information
|
||||||
|
regarding copyright ownership. The ASF licenses this file
|
||||||
|
to you under the Apache License, Version 2.0 (the
|
||||||
|
"License"); you may not use this file except in compliance
|
||||||
|
with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing,
|
||||||
|
software distributed under the License is distributed on an
|
||||||
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations
|
||||||
|
under the License.
|
||||||
|
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The interest posting report becomes per branch: each branch approves and posts its own
|
||||||
|
customers' interest, product by product, at its own day close, so every branch's books carry
|
||||||
|
their own interest cost and balance on their own.
|
||||||
|
|
||||||
|
office_id names the branch a report is for. approved_on_behalf marks a report the head office
|
||||||
|
approved for a branch that did not open that day.
|
||||||
|
|
||||||
|
A posting run was one per product and date; with a report per branch there are several, so a
|
||||||
|
run now belongs to the report it posted.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="0590-1-report-office">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><columnExists tableName="m_savings_interest_posting_report" columnName="office_id"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_savings_interest_posting_report">
|
||||||
|
<column name="office_id" type="BIGINT">
|
||||||
|
<constraints foreignKeyName="fk_sipr_office" referencedTableName="m_office" referencedColumnNames="id"/>
|
||||||
|
</column>
|
||||||
|
<column name="approved_on_behalf" type="BOOLEAN" defaultValueBoolean="false">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</addColumn>
|
||||||
|
<createIndex tableName="m_savings_interest_posting_report" indexName="idx_sipr_office_date">
|
||||||
|
<column name="office_id"/>
|
||||||
|
<column name="posting_date"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Reports from before this change were bank-wide; each is given the branch its accounts belong to. -->
|
||||||
|
<changeSet author="fineract" id="0590-1b-backfill-report-office">
|
||||||
|
<sql>
|
||||||
|
UPDATE m_savings_interest_posting_report r
|
||||||
|
SET office_id = (SELECT MIN(l.office_id) FROM m_savings_interest_posting_report_line l WHERE l.report_id = r.id)
|
||||||
|
WHERE r.office_id IS NULL
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet author="fineract" id="0590-2-run-per-report">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><columnExists tableName="m_savings_interest_posting_run" columnName="report_id"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<dropUniqueConstraint tableName="m_savings_interest_posting_run" constraintName="uq_savings_posting_run_product_date"/>
|
||||||
|
<addColumn tableName="m_savings_interest_posting_run">
|
||||||
|
<column name="report_id" type="BIGINT"/>
|
||||||
|
<column name="office_id" type="BIGINT"/>
|
||||||
|
</addColumn>
|
||||||
|
<addUniqueConstraint tableName="m_savings_interest_posting_run" columnNames="report_id"
|
||||||
|
constraintName="uq_savings_posting_run_report"/>
|
||||||
|
<createIndex tableName="m_savings_interest_posting_run" indexName="idx_sipr_run_product_date">
|
||||||
|
<column name="savings_product_id"/>
|
||||||
|
<column name="posting_date"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
</databaseChangeLog>
|
||||||
Reference in New Issue
Block a user