feat(savings,shares): bidirectional savings/share account linking, optional savings link on share create

- Savings account creation validates and stores a required "shareAccountId" once Share Linking is
  enabled, checked against the client's own active share accounts (raw SQL - fineract-savings has
  no dependency on the shareaccounts domain). On save, mirrors the link back onto the share
  account's own savings_account_id, the same way that field already gets set from the share side.
- New GET /clients/{id}/activeshareaccounts - a client's active share accounts, for the savings
  creation screen's picker (kept separate from the shared Savings/FD/RD/Current template DTO).
- ShareAccountDataSerializer.validateAndCreate no longer requires savingsAccountId on create - the
  DB column and domain relationship were already nullable, only this validation blocked it.

fix(branchday): stop the denomination carry-forward preview from doubling yesterday's closing count

GET /branchday/denominations classified every detail row that wasn't literally leg='OPENING' as
"closing" - which, once CLOSING_VAULT rows existed alongside CLOSING (single-teller offices record
the same breakdown under both), silently doubled every count shown as "yesterday's closing" when
opening the next business day. Filters to leg IN ('OPENING','CLOSING') explicitly instead of an
else-catches-everything branch.

feat(accounting): seed missing Income/Expense/Transfers-Suspense/Escheat-Liability accounts

The Basoda-derived chart of accounts (0523) carried none of these - a balance sheet has no P&L
heads, and Transfers Suspense/Escheat Liability aren't balance-sheet line items either - so a
Savings Product's accounting mapping had nowhere to point those required fields. Same guard as
0523: only seeds a Basoda-derived tenant that hasn't already gotten it, never touches a tenant with
its own chart of accounts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 22:17:54 +05:30
co-authored by Claude Sonnet 5
parent 509370eaeb
commit dc0539d53d
14 changed files with 424 additions and 12 deletions
@@ -212,6 +212,24 @@ public class BranchDayRegisterApiResource {
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
final Map<String, Object> res = openDayInternal(currentUser, officeId, businessDate, openingCash, denominations,
openingDifferenceReason);
return gson.toJson(res);
}
/**
* The actual open-day work, factored out of {@link #openDay(String)} so
* {@link #migrateCashDenomination(String)} can drive it directly with a denomination breakdown it
* has already reconciled against the cash GL account, without round-tripping through JSON.
*/
private Map<String, Object> openDayInternal(final AppUser currentUser, final Long officeId, final LocalDate businessDate,
final BigDecimal openingCash, final List<Map<String, Object>> denominations, final String openingDifferenceReason) {
// A branch with no vault yet (a freshly set-up institution, before anyone has visited Manage
// Tellers) cannot record a denomination breakdown at all — vault-position tracking is
// per-teller. Rather than blocking the very first day open on a separate setup step, open a
// day self-provisions the office's vault the same way its GL was self-provisioned.
ensureTellerExists(officeId, businessDate);
// A branch runs one day at a time. Opening a second while the first is still open is how a
// day gets stranded, so the previous one has to be closed first.
final Map<String, Object> open = findOpenDay(officeId);
@@ -281,9 +299,164 @@ public class BranchDayRegisterApiResource {
res.put("businessDate", businessDate.toString());
res.put("openingCash", carried == null ? BigDecimal.ZERO : carried);
res.put("status", "OPEN");
return res;
}
// ── POST /v1/branchday/migrate-cash-denomination ────────────────────────────
/**
* GET /v1/branchday/cash-gl-status?officeId=X
*
* What the office's cash-at-vault GL account currently holds, so the migration screen can show
* "put this much into the drawer" before anyone starts counting, and so the frontend knows
* whether this is a first-time setup (no balance posted yet, the count itself will define it) or
* a top-up (a balance already exists, the count must match it exactly).
*/
@GET
@Path("cash-gl-status")
public String cashGlStatus(@QueryParam("officeId") final Long officeIdParam) {
final AppUser currentUser = this.context.authenticatedUser();
final Long officeId = resolveOffice(officeIdParam, currentUser);
final Map<String, Object> result = new LinkedHashMap<>();
result.put("officeId", officeId);
try {
final Map<String, Object> gl = resolveCashGlAccount();
final Long glAccountId = ((Number) gl.get("id")).longValue();
final BigDecimal currentBalance = currentGlBalance(glAccountId, officeId);
result.put("glAccountId", glAccountId);
result.put("glAccountName", gl.get("name"));
result.put("glCode", gl.get("gl_code"));
result.put("currentBalance", currentBalance);
result.put("hasBalance", currentBalance.compareTo(BigDecimal.ZERO) != 0);
} catch (final PlatformDataIntegrityException e) {
result.put("glAccountId", null);
result.put("configured", false);
result.put("message", e.getDefaultUserMessage());
}
return gson.toJson(result);
}
/**
* POST /v1/branchday/migrate-cash-denomination
*
* The one-step "new institution" path from a GL that was just seeded (Migrate Opening Balances,
* or nothing at all yet) straight to a countable vault: count the drawer, and this either
* (a) defines the cash GL's opening balance FROM that count, when nothing has been posted to it
* yet, or (b) requires the count to match what was already posted, when it has - the same
* "physical count is the source of truth, but it still has to reconcile" rule the ordinary
* Open Business Day screen applies against yesterday's closing figure. Either way, it then opens
* the business day with that same breakdown, so this single action is the whole "day 1" story.
*/
@POST
@Path("migrate-cash-denomination")
@Consumes(MediaType.APPLICATION_JSON)
public String migrateCashDenomination(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
requireDayAuthority(currentUser);
final var root = this.fromJsonHelper.parse(body);
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
final LocalDate businessDate = extractDate(root, "businessDate",
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
final List<Map<String, Object>> denominations = extractDenominations(root, "denominations");
final String openingDifferenceReason = this.fromJsonHelper.extractStringNamed("openingDifferenceReason", root);
final BigDecimal denominationTotal = sumDenominations(denominations);
if (denominationTotal == null || denominationTotal.compareTo(BigDecimal.ZERO) <= 0) {
throw new PlatformDataIntegrityException("error.msg.branchday.denomination.required",
"Count at least one denomination before migrating.");
}
final Map<String, Object> gl = resolveCashGlAccount();
final Long glAccountId = ((Number) gl.get("id")).longValue();
final BigDecimal currentBalance = currentGlBalance(glAccountId, officeId);
final boolean glAlreadyHasABalance = currentBalance.compareTo(BigDecimal.ZERO) != 0;
if (glAlreadyHasABalance) {
if (denominationTotal.compareTo(currentBalance) != 0) {
throw new PlatformDataIntegrityException("error.msg.branchday.cash.gl.mismatch",
"The cash GL account (" + gl.get("name") + ") already carries Rs. " + currentBalance
+ " — the counted denomination total (Rs. " + denominationTotal
+ ") must match it exactly.");
}
} else {
postCashOpeningBalance(officeId, businessDate, glAccountId, denominationTotal, currentUser);
}
final Map<String, Object> res = openDayInternal(currentUser, officeId, businessDate, null, denominations,
openingDifferenceReason);
res.put("glAccountId", glAccountId);
res.put("glAccountName", gl.get("name"));
res.put("glOpeningBalancePosted", !glAlreadyHasABalance);
return gson.toJson(res);
}
/** The GL account this tenant's cash-at-vault / cash-at-teller financial activities are mapped
* to — the one account this office's counted cash is supposed to agree with. */
private Map<String, Object> resolveCashGlAccount() {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT g.id, g.name, g.gl_code FROM acc_gl_financial_activity_account f
JOIN acc_gl_account g ON g.id = f.gl_account_id
WHERE f.financial_activity_type IN (101, 102)
ORDER BY f.financial_activity_type LIMIT 1
""");
if (rows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.cash.account.not.configured",
"No Cash at Vault / Cash at Teller account is configured for this tenant yet. "
+ "Map Financial Activity 101 (or 102) to a GL account first.");
}
return rows.get(0);
}
/** Net Dr-minus-Cr posted to this GL account for this office so far — zero means nothing has
* ever been posted to it, the ordinary "brand new ledger" state. */
private BigDecimal currentGlBalance(final Long glAccountId, final Long officeId) {
final BigDecimal net = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE WHEN type_enum = 2 THEN amount ELSE -amount END), 0)
FROM acc_gl_journal_entry
WHERE account_id = ? AND office_id = ? AND reversed = false
""", BigDecimal.class, glAccountId, officeId);
return net == null ? BigDecimal.ZERO : net;
}
/** Posts a single-account opening balance: Dr the cash GL, Cr the Opening Balances Transfer
* Contra account (financial activity 300) - the same contra leg Fineract's own "Define Opening
* Balances" journal entry uses, just scoped to the one account this screen is responsible for
* instead of the whole chart of accounts. */
private void postCashOpeningBalance(final Long officeId, final LocalDate businessDate, final Long cashGlAccountId,
final BigDecimal amount, final AppUser currentUser) {
final List<Long> contraIds = jdbcTemplate.queryForList("""
SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = 300
""", Long.class);
if (contraIds.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.opening.balance.contra.not.configured",
"The Opening Balances Transfer Contra account (Financial Activity 300) is not configured for this tenant.");
}
final Long contraId = contraIds.get(0);
final String currencyCode = jdbcTemplate.queryForObject(
"SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class);
final String transactionId = "CASHMIG-" + officeId + "-" + businessDate.toString().replace("-", "") + "-"
+ System.nanoTime();
insertJournalEntry(officeId, cashGlAccountId, currencyCode, transactionId, businessDate, 2, amount, currentUser);
insertJournalEntry(officeId, contraId, currencyCode, transactionId, businessDate, 1, amount, currentUser);
}
/** type_enum: 2 = DEBIT, 1 = CREDIT (matches org.apache.fineract.accounting.journalentry.domain.JournalEntryType). */
private void insertJournalEntry(final Long officeId, final Long accountId, final String currencyCode,
final String transactionId, final LocalDate entryDate, final int typeEnum, final BigDecimal amount,
final AppUser currentUser) {
jdbcTemplate.update("""
INSERT INTO acc_gl_journal_entry
(office_id, account_id, currency_code, transaction_id, reversed, manual_entry, entry_date, type_enum,
amount, created_by, last_modified_by, created_date, lastmodified_date, created_on_utc,
last_modified_on_utc, submitted_on_date, transaction_date)
VALUES (?,?,?,?,false,true,?,?,?,?,?,now(),now(),now(),now(),?,?)
""", officeId, accountId, currencyCode, transactionId, Date.valueOf(entryDate), typeEnum, amount,
currentUser.getId(), currentUser.getId(), Date.valueOf(DateUtils.getBusinessLocalDate()), Date.valueOf(entryDate));
}
// ── POST /v1/branchday/close ──────────────────────────────────────────────
@POST
@@ -384,10 +557,17 @@ public class BranchDayRegisterApiResource {
}
final Long registerId = registerIds.get(0);
// Vault-only legs (OPENING_VAULT/CLOSING_VAULT - see VaultPositionService) live in this same
// table alongside the branch-total OPENING/CLOSING legs this endpoint is actually about. An
// office with a single teller records the same denomination breakdown under both pairs of
// legs, so an unfiltered "everything that isn't OPENING must be closing" catch-all silently
// folds the vault-only rows into "closing" too and doubles every count - explicit leg names,
// not an else branch, is what keeps this endpoint to the branch total it was always meant to
// report.
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT leg, denomination_id, denomination_value, count, total_amount
FROM m_branch_day_register_detail
WHERE register_id = ?
WHERE register_id = ? AND leg IN ('OPENING', 'CLOSING')
ORDER BY denomination_value DESC
""", registerId);
@@ -707,6 +887,41 @@ public class BranchDayRegisterApiResource {
return v == null ? BigDecimal.ZERO : v;
}
/**
* Creates the office's own vault (teller) the first time its business day is ever opened, so a
* freshly set-up institution can go straight from Migrate Opening Balances to Open Business Day
* without a separate trip through Manage Tellers first. Left untouched once one exists — this
* only provisions, it never re-configures an office's existing vault setup.
*/
private void ensureTellerExists(final Long officeId, final LocalDate businessDate) {
final Integer existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_tellers WHERE office_id = ?",
Integer.class, officeId);
if (existing != null && existing > 0) {
return;
}
// The same GL account the branch's own cash-at-vault/cash-at-teller financial activities are
// mapped to (set up alongside the office's chart of accounts) - reusing it here is what keeps
// the vault's physical cash and the branch's own "Cash on Hand" ledger balance pointed at the
// same account, rather than inventing a second one nobody configured.
final Long cashAccountId = ((Number) resolveCashGlAccount().get("id")).longValue();
final String officeName = jdbcTemplate.queryForObject("SELECT name FROM m_office WHERE id = ?", String.class,
officeId);
String tellerName = officeName + " Vault";
final Integer nameTaken = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_tellers WHERE name = ?", Integer.class,
tellerName);
if (nameTaken != null && nameTaken > 0) {
tellerName = tellerName + " " + officeId;
}
jdbcTemplate.update("""
INSERT INTO m_tellers (name, office_id, description, state, valid_from, debit_account_id, credit_account_id)
VALUES (?, ?, ?, 300, ?, ?, ?)
""", tellerName, officeId, "Auto-provisioned on first business day open", Date.valueOf(businessDate),
cashAccountId, cashAccountId);
}
private Long resolveOffice(final Long requested, final AppUser user) {
if (requested != null) {
return requested;
@@ -43,6 +43,7 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominati
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.autoDebitFacilityParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.introducerRequiredParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shareLinkingEnabledParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shareAccountIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isSeniorCitizenParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.seniorCitizenInterestRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.allowOverdraftParamName;
@@ -81,6 +82,7 @@ import java.util.List;
import java.util.Set;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
@@ -406,6 +408,28 @@ public class SavingsAccountAssembler {
account.setShareLinkingEnabled(account.savingsProduct().isShareLinkingEnabled());
}
// Which share account this savings account is linked to - required once Share Linking is
// enabled, since a "Yes" with nothing chosen would leave the toggle meaning nothing. Checked
// against m_share_account directly (raw SQL, not the JPA entity - fineract-savings has no
// dependency on the shareaccounts domain class) rather than trusted blindly, the same way an
// account number typed for a linked savings/loan account elsewhere in this form is verified
// before being accepted.
if (Boolean.TRUE.equals(account.getShareLinkingEnabled())) {
final Long shareAccountId = this.fromApiJsonHelper.extractLongNamed(shareAccountIdParamName, element);
if (shareAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.required",
"A share account must be chosen when Share Linking is enabled.");
}
final Integer matches = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_account WHERE id = ? AND client_id = ? AND status_enum = 300", Integer.class,
shareAccountId, account.clientId());
if (matches == null || matches == 0) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.not.active.for.client",
"Share account " + shareAccountId + " is not an active share account for this client.");
}
account.setShareAccountId(shareAccountId);
}
// Senior citizen rate — runs after validateDomainRules so it is the final persisted value
if (command.parameterExists(isSeniorCitizenParamName)) {
final Boolean scFlag = this.fromApiJsonHelper.extractBooleanNamed(isSeniorCitizenParamName, element);
@@ -116,6 +116,7 @@ public class SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
private final GroupRepositoryWrapper groupRepositoryWrapper;
private final GroupSavingsIndividualMonitoringWritePlatformService gsimWritePlatformService;
private final ClientNomineeWritePlatformService clientNomineeWritePlatformService;
private final org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
@Transactional
@Override
@@ -257,6 +258,16 @@ public class SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
}
final Long savingsId = account.getId();
// Mirrors the link back onto the share account this savings account was linked to at
// creation (share account -> savings account already existed; this is the reverse
// pointer), so either side can be navigated to from the other, the same way the "Default
// Savings Account" chosen on a share account points back at it.
if (account.getShareAccountId() != null) {
this.jdbcTemplate.update("UPDATE m_share_account SET savings_account_id = ? WHERE id = ?", savingsId,
account.getShareAccountId());
}
if (command.parameterExists(SavingsApiConstants.datatables)) {
this.entityDatatableChecksWritePlatformService.saveDatatables(StatusEnum.CREATE.getValue(), EntityTables.SAVINGS.getName(),
savingsId, account.productId(), command.arrayOfParameterNamed(SavingsApiConstants.datatables));
@@ -518,13 +518,13 @@ public class SavingsConfiguration {
AccountNumberFormatRepositoryWrapper accountNumberFormatRepository, BusinessEventNotifierService businessEventNotifierService,
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, GSIMRepositoy gsimRepository,
GroupRepositoryWrapper groupRepositoryWrapper, GroupSavingsIndividualMonitoringWritePlatformService gsimWritePlatformService,
ClientNomineeWritePlatformService clientNomineeWritePlatformService) {
ClientNomineeWritePlatformService clientNomineeWritePlatformService, org.springframework.jdbc.core.JdbcTemplate jdbcTemplate) {
return new SavingsApplicationProcessWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepository, savingAccountAssembler,
savingsAccountDataValidator, accountNumberGenerator, clientRepository, groupRepository, savingsProductRepository,
noteRepository, staffRepository, savingsAccountApplicationTransitionApiJsonValidator, savingsAccountChargeAssembler,
commandProcessingService, savingsAccountDomainService, savingsAccountWritePlatformService, accountNumberFormatRepository,
businessEventNotifierService, entityDatatableChecksWritePlatformService, gsimRepository, groupRepositoryWrapper,
gsimWritePlatformService, clientNomineeWritePlatformService);
gsimWritePlatformService, clientNomineeWritePlatformService, jdbcTemplate);
}
@Bean
@@ -0,0 +1,67 @@
/**
* 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.shareaccounts.api;
import com.google.gson.Gson;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* GET /v1/clients/{clientId}/activeshareaccounts
*
* A client's active share accounts, for the "Default Share Account" picker the Savings Account
* creation screen shows once Share Linking is enabled - the reverse of the "Default Savings
* Account" picker the Share Account creation screen already shows (which comes from
* {@code ShareAccountReadPlatformServiceImpl#retrieveActiveForLookup}). Deliberately its own small
* endpoint rather than folded into the savings account template response, which is shared by
* Savings, Fixed Deposit, Recurring Deposit and Current accounts alike - bolting a share-specific
* list onto that one shared, deeply-nested DTO would touch every one of those account types for a
* field only Savings actually uses.
*/
@Path("/v1/clients/{clientId}/activeshareaccounts")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class ClientActiveShareAccountsApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
@GET
public String retrieve(@PathParam("clientId") final Long clientId) {
this.context.authenticatedUser();
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sa.id AS id, sa.account_no AS "accountNo", sp.name AS "productName"
FROM m_share_account sa JOIN m_share_product sp ON sp.id = sa.product_id
WHERE sa.client_id = ? AND sa.status_enum = 300
ORDER BY sa.account_no
""", clientId);
return gson.toJson(rows);
}
}
@@ -368,7 +368,7 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
}
public String getSavingsAccountNo() {
return this.savingsAccount.getAccountNumber();
return this.savingsAccount == null ? null : this.savingsAccount.getAccountNumber();
}
public void addAddtionalShares(Set<ShareAccountTransaction> additionalShares) {
@@ -158,8 +158,10 @@ public class ShareAccountDataSerializer {
baseDataValidator.reset().parameter(ShareAccountApiConstants.resolutionnumber_paramname).value(resolutionNumber).notBlank();
Long savingsAccountId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.savingsaccountid_paramname, element);
baseDataValidator.reset().parameter(ShareAccountApiConstants.savingsaccountid_paramname).value(savingsAccountId).notNull()
.longGreaterThanZero();
if (savingsAccountId != null) {
baseDataValidator.reset().parameter(ShareAccountApiConstants.savingsaccountid_paramname).value(savingsAccountId)
.longGreaterThanZero();
}
final Long requestedShares = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.requestedshares_paramname, element);
baseDataValidator.reset().parameter(ShareAccountApiConstants.requestedshares_paramname).value(requestedShares).notNull()
.longGreaterThanZero();
@@ -200,12 +202,15 @@ public class ShareAccountDataSerializer {
}
Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
if (!this.savingsAccountReadPlatformService.isAccountBelongsToClient(clientId, savingsAccountId, DepositAccountType.SAVINGS_DEPOSIT,
shareProduct.getCurrency().getCode())) {
throw new SavingsAccountNotFoundException(savingsAccountId);
SavingsAccount savingsAccount = null;
if (savingsAccountId != null) {
if (!this.savingsAccountReadPlatformService.isAccountBelongsToClient(clientId, savingsAccountId,
DepositAccountType.SAVINGS_DEPOSIT, shareProduct.getCurrency().getCode())) {
throw new SavingsAccountNotFoundException(savingsAccountId);
}
savingsAccount = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(savingsAccountId,
DepositAccountType.SAVINGS_DEPOSIT);
}
SavingsAccount savingsAccount = this.savingsAccountRepositoryWrapper.findOneWithNotFoundDetection(savingsAccountId,
DepositAccountType.SAVINGS_DEPOSIT);
final MonetaryCurrency currency = shareProduct.getCurrency();
Set<ShareAccountCharge> charges = assembleListOfAccountCharges(element, currency.getCode());
@@ -50,4 +50,5 @@
<include file="parts/0521_branch_day_register_vault_only_detail.xml" relativeToChangelogFile="true"/>
<include file="parts/0522_seed_opening_balance_contra_account.xml" relativeToChangelogFile="true"/>
<include file="parts/0523_seed_basoda_bank_chart_of_accounts.xml" relativeToChangelogFile="true"/>
<include file="parts/0524_seed_basoda_bank_income_expense_accounts.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- 0523 seeded a Basoda-derived tenant's chart of accounts strictly from its balance sheet,
which carries no Income or Expense heads at all (a balance sheet has none to carry) and no
"Transfers in Suspense" / "Escheat Liability" leaves either (neither is a balance-sheet line
item, both are accounting-mapping plumbing). Without them a Savings Product's accounting
mapping screen has nowhere to point its required Income/Expense/Transfers/Escheat fields and
cannot be saved. This fills exactly that gap, on the same Basoda-derived tenant 0523 already
seeded - never on a tenant with its own, different chart of accounts. -->
<changeSet id="0524_seed_basoda_bank_income_expense_accounts" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<and>
<sqlCheck expectedResult="1">SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '301000'</sqlCheck>
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM acc_gl_account WHERE gl_code = '401000'</sqlCheck>
</and>
</preConditions>
<sql>
<![CDATA[
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('TRANSFERS IN SUSPENSE A/C', '202014', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='202000')),
('ESCHEAT LIABILITY A/C (DEAF)', '202015', false, true, 1, 2, (SELECT id FROM acc_gl_account WHERE gl_code='202000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('INCOME', '401000', false, true, 2, 4);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('INCOME FROM FEES', '401001', false, true, 1, 4, (SELECT id FROM acc_gl_account WHERE gl_code='401000')),
('INCOME FROM PENALTIES', '401002', false, true, 1, 4, (SELECT id FROM acc_gl_account WHERE gl_code='401000'));
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum) VALUES
('EXPENSES', '501000', false, true, 2, 5);
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) VALUES
('INTEREST ON SAVINGS ACCOUNT', '501001', false, true, 1, 5, (SELECT id FROM acc_gl_account WHERE gl_code='501000')),
('WRITE OFF ACCOUNT', '501002', false, true, 1, 5, (SELECT id FROM acc_gl_account WHERE gl_code='501000'));
]]>
</sql>
</changeSet>
</databaseChangeLog>