Compare commits
2
Commits
509370eaeb
...
c4a99fea5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4a99fea5d | ||
|
|
dc0539d53d |
+1
@@ -229,6 +229,7 @@ public class SavingsApiConstants {
|
||||
public static final String endDateParamName = "endDate";
|
||||
public static final String introducerRequiredParamName = "introducerRequired";
|
||||
public static final String shareLinkingEnabledParamName = "shareLinkingEnabled";
|
||||
public static final String shareAccountIdParamName = "shareAccountId";
|
||||
public static final String nominationFacilityParamName = "nominationFacility";
|
||||
public static final String dormancyAlertParamName = "dormancyAlert";
|
||||
public static final String interestCalculateSelectorParamName = "interestCalculateSelector";
|
||||
|
||||
+2
@@ -157,6 +157,8 @@ public final class SavingsAccountData implements Serializable {
|
||||
private LocalDate endDate;
|
||||
private Boolean introducerRequired;
|
||||
private Boolean shareLinkingEnabled;
|
||||
private Long shareAccountId;
|
||||
private String shareAccountNo;
|
||||
private Boolean nominationFacility;
|
||||
private Boolean dormancyAlert;
|
||||
private Boolean isDormancyTrackingActiveFromProduct;
|
||||
|
||||
+216
-1
@@ -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;
|
||||
|
||||
+12
-4
@@ -208,8 +208,9 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String list(@QueryParam("status") @DefaultValue("PENDING") final String status,
|
||||
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
|
||||
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
|
||||
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, offset, limit);
|
||||
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
|
||||
@QueryParam("search") final String search) {
|
||||
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, search, offset, limit);
|
||||
}
|
||||
|
||||
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
|
||||
@@ -227,11 +228,11 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
where.append(" AND status = ?");
|
||||
params.add(status);
|
||||
}
|
||||
return queryList(where.toString(), params, fromDate, toDate, offset, limit);
|
||||
return queryList(where.toString(), params, fromDate, toDate, null, offset, limit);
|
||||
}
|
||||
|
||||
private String queryList(final String baseWhere, final List<Object> baseParams, final String fromDate, final String toDate,
|
||||
final int offset, final int limit) {
|
||||
final String search, final int offset, final int limit) {
|
||||
final StringBuilder where = new StringBuilder(baseWhere);
|
||||
final List<Object> params = new ArrayList<>(baseParams);
|
||||
if (fromDate != null && !fromDate.isBlank()) {
|
||||
@@ -242,6 +243,13 @@ public class ClerkShareWithdrawalRequestApiResource {
|
||||
where.append(" AND DATE(submitted_at) <= ?");
|
||||
params.add(Date.valueOf(toDate));
|
||||
}
|
||||
if (search != null && !search.isBlank()) {
|
||||
where.append(" AND (account_no ILIKE ? OR client_name ILIKE ? OR resolution_number ILIKE ?)");
|
||||
final String like = "%" + search.trim() + "%";
|
||||
params.add(like);
|
||||
params.add(like);
|
||||
params.add(like);
|
||||
}
|
||||
|
||||
final long total = Optional
|
||||
.ofNullable(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_share_withdrawal_request " + where, Long.class,
|
||||
|
||||
+10
-1
@@ -158,7 +158,8 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
@QueryParam("offset") @DefaultValue("0") final int offset,
|
||||
@QueryParam("limit") @DefaultValue("20") final int limit,
|
||||
@QueryParam("fromDate") final String fromDate,
|
||||
@QueryParam("toDate") final String toDate) {
|
||||
@QueryParam("toDate") final String toDate,
|
||||
@QueryParam("search") final String search) {
|
||||
|
||||
final StringBuilder where = new StringBuilder("WHERE status = ?");
|
||||
final List<Object> baseParams = new ArrayList<>(List.of(status));
|
||||
@@ -170,6 +171,14 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
where.append(" AND DATE(submitted_at) <= ?");
|
||||
baseParams.add(Date.valueOf(toDate));
|
||||
}
|
||||
if (search != null && !search.isBlank()) {
|
||||
where.append(" AND (account_no ILIKE ? OR client_name ILIKE ? OR voucher_no ILIKE ? OR tok_no ILIKE ?)");
|
||||
final String like = "%" + search.trim() + "%";
|
||||
baseParams.add(like);
|
||||
baseParams.add(like);
|
||||
baseParams.add(like);
|
||||
baseParams.add(like);
|
||||
}
|
||||
|
||||
final long total = Optional.ofNullable(
|
||||
jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_clerk_withdrawal_request " + where, Long.class, baseParams.toArray())
|
||||
|
||||
+24
@@ -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);
|
||||
|
||||
+4
@@ -883,6 +883,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
sqlBuilder.append("COALESCE(sa.auto_debit_facility, sp.auto_debit_facility) as autoDebitFacility, ");
|
||||
sqlBuilder.append("COALESCE(sa.introducer_required, sp.introducer_required) as introducerRequired, ");
|
||||
sqlBuilder.append("COALESCE(sa.share_linking_enabled, sp.share_linking_enabled) as shareLinkingEnabled, ");
|
||||
sqlBuilder.append("sa.share_account_id as shareAccountId, msha.account_no as shareAccountNo, ");
|
||||
// Facility config fields
|
||||
sqlBuilder.append("safc.atm_facility as atmFacility, ");
|
||||
sqlBuilder.append("safc.atm_withdrawal_facility as atmWithdrawalFacility, ");
|
||||
@@ -915,6 +916,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
sqlBuilder.append("left join m_appuser cbu on cbu.id = sa.closedon_userid ");
|
||||
sqlBuilder.append("left join m_tax_group tg on tg.id = sa.tax_group_id ");
|
||||
sqlBuilder.append("left join m_savings_account_facility_config safc on safc.savings_account_id = sa.id ");
|
||||
sqlBuilder.append("left join m_share_account msha on msha.id = sa.share_account_id ");
|
||||
|
||||
this.schemaSql = sqlBuilder.toString();
|
||||
}
|
||||
@@ -1141,6 +1143,8 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
accountData.setAutoDebitFacility(rs.getObject("autoDebitFacility") != null ? rs.getBoolean("autoDebitFacility") : null);
|
||||
accountData.setIntroducerRequired(rs.getObject("introducerRequired") != null ? rs.getBoolean("introducerRequired") : null);
|
||||
accountData.setShareLinkingEnabled(rs.getObject("shareLinkingEnabled") != null ? rs.getBoolean("shareLinkingEnabled") : null);
|
||||
accountData.setShareAccountId(JdbcSupport.getLong(rs, "shareAccountId"));
|
||||
accountData.setShareAccountNo(rs.getString("shareAccountNo"));
|
||||
accountData.setTerminateTrue(rs.getObject("terminateTrue") != null ? rs.getBoolean("terminateTrue") : null);
|
||||
|
||||
// Nominee as parsed map
|
||||
|
||||
+11
@@ -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));
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+67
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+12
-7
@@ -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>
|
||||
|
||||
+45
@@ -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>
|
||||
+2
-1
@@ -48,7 +48,8 @@ public class SavingsAccountConstant extends SavingsApiConstants {
|
||||
introducerIdParamName, introducerNameParamName, introducerCifNumberParamName,
|
||||
nomineeParamName, "nominees", linkedAccountIdParamName, linkedAccountTypeParamName, linkedAccountNoParamName,
|
||||
accountFacilityConfigParamName, jointHoldersParamName,
|
||||
balanceSwapOptionParamName, msmeParamName, autoDebitFacilityParamName, introducerRequiredParamName, shareLinkingEnabledParamName));
|
||||
balanceSwapOptionParamName, msmeParamName, autoDebitFacilityParamName, introducerRequiredParamName, shareLinkingEnabledParamName,
|
||||
shareAccountIdParamName));
|
||||
|
||||
/**
|
||||
* These parameters will match the class level parameters of {@link SavingsAccountData}. Where possible, we try to
|
||||
|
||||
+13
@@ -4426,6 +4426,16 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
@Column(name = "share_linking_enabled", nullable = false, columnDefinition = "boolean default false")
|
||||
private Boolean shareLinkingEnabled = false;
|
||||
|
||||
/**
|
||||
* Plain id, not a JPA relationship - fineract-savings cannot depend on the shareaccounts domain
|
||||
* class (it lives in fineract-provider, which depends on fineract-savings, not the other way
|
||||
* round). The write side resolves and validates this id against m_share_account directly; this
|
||||
* column just carries it back to the share account's own {@code savings_account_id}, mirroring
|
||||
* the link from both ends.
|
||||
*/
|
||||
@Column(name = "share_account_id")
|
||||
private Long shareAccountId;
|
||||
|
||||
@Column(name = "nomination_facility", nullable = false, columnDefinition = "boolean default false")
|
||||
private Boolean nominationFacility = false;
|
||||
|
||||
@@ -4532,6 +4542,9 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
public Boolean getShareLinkingEnabled() { return shareLinkingEnabled; }
|
||||
public void setShareLinkingEnabled(Boolean v) { this.shareLinkingEnabled = v; }
|
||||
|
||||
public Long getShareAccountId() { return shareAccountId; }
|
||||
public void setShareAccountId(Long v) { this.shareAccountId = v; }
|
||||
|
||||
public boolean isSeniorCitizen() { return isSeniorCitizen; }
|
||||
public void setSeniorCitizen(boolean v) { this.isSeniorCitizen = v; }
|
||||
|
||||
|
||||
+1
@@ -53,4 +53,5 @@
|
||||
<include file="parts/2029_remove_proposed_payload_json.xml" relativeToChangelogFile="true" />
|
||||
<include file="parts/2030_add_savings_account_pending_edit_table.xml" relativeToChangelogFile="true" />
|
||||
<include file="parts/2031_add_savings_account_edit_approval_permissions.xml" relativeToChangelogFile="true" />
|
||||
<include file="parts/2032_add_share_account_id_to_savings_account.xml" relativeToChangelogFile="true" />
|
||||
</databaseChangeLog>
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- The reverse of m_share_account.savings_account_id (0489-era, already existed): this is the
|
||||
savings account's own pointer back to the share account it was linked to at creation, when
|
||||
Share Linking Enabled was set. No FK to m_share_account here - fineract-savings has no
|
||||
reference to that table's owning module, so the write side is responsible for validating
|
||||
this id and for keeping the two ends of the link in sync. -->
|
||||
|
||||
<changeSet id="2032_add_share_account_id_to_savings_account" author="cbs-nitesh">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_savings_account"/>
|
||||
<not><columnExists tableName="m_savings_account" columnName="share_account_id"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_savings_account">
|
||||
<column name="share_account_id" type="BIGINT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user