feat(share): add cashier share collection endpoint with GL posting and activation validation
- New POST /cashier/sharecollection endpoint: collects cash for share purchase, posts Dr Cash Vault / Cr Share Sundry / Cr Income GL entries, marks charges as paid and sets cashier_collected flag on the account - GET /cashier/sharecollection/:id endpoint for collection status check - Block share account activation if cashier has not collected cash first (validates m_share_cashier_collection record within last 30 days) - Skip duplicate charge GL on activation when charge already collected upfront - Suppress automatic GL posting at account submission time to prevent double entries; GL is now posted only via cashier collection endpoint - Fix JdbcTemplate injection in ShareAccountWritePlatformServiceJpaRepositoryImpl via ShareAccountsConfiguration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
6e31ac9acd
commit
9d944d0196
+424
@@ -0,0 +1,424 @@
|
|||||||
|
package org.apache.fineract.portfolio.cashtransfer.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
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.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cashier Share Collection API — POST /v1/cashier/sharecollection
|
||||||
|
*
|
||||||
|
* Records cash received from a member for share purchase and posts GL entries:
|
||||||
|
* Dr SHARES_REFERENCE (Cash at Vault) = shareAmount + chargeAmount
|
||||||
|
* Cr SHARES_SUSPENSE (Share Sundry) = shareAmount
|
||||||
|
* Cr INCOME_FROM_FEES = chargeAmount (if > 0)
|
||||||
|
*
|
||||||
|
* On account activation Fineract posts:
|
||||||
|
* Dr SHARES_SUSPENSE → Cr SHARES_EQUITY
|
||||||
|
* Activation skips charge GL if is_paid_derived = true (set here).
|
||||||
|
*/
|
||||||
|
@Path("/v1/cashier/sharecollection")
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
public class ShareCollectionApiResource {
|
||||||
|
|
||||||
|
// ── acc_gl_journal_entry.type_enum ─────────────────────────────────────
|
||||||
|
// Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr)
|
||||||
|
// Opposite of intuition — verified from Fineract AccountingProcessorHelper.
|
||||||
|
private static final int DEBIT = 2;
|
||||||
|
private static final int CREDIT = 1;
|
||||||
|
|
||||||
|
// ── CashAccountsForShares financial_account_type ───────────────────────
|
||||||
|
private static final int FA_SHARES_REFERENCE = 1;
|
||||||
|
private static final int FA_SHARES_SUSPENSE = 2;
|
||||||
|
private static final int FA_INCOME_FROM_FEES = 3;
|
||||||
|
|
||||||
|
// ── acc_gl_journal_entry.entity_type_enum ─────────────────────────────
|
||||||
|
// Manual cashier entries use NULL — avoids NPE in PortfolioProductType.fromInt()
|
||||||
|
// which only handles values 1-5 and returns null for unknown values.
|
||||||
|
|
||||||
|
// ── acc_product_mapping.product_type for shares ────────────────────────
|
||||||
|
private static final int PRODUCT_TYPE_SHARES = 4;
|
||||||
|
|
||||||
|
// ── m_share_account.status_enum ───────────────────────────────────────
|
||||||
|
private static final int STATUS_SUBMITTED_PENDING = 100;
|
||||||
|
|
||||||
|
// ── Maximum days a collection record is considered valid ──────────────
|
||||||
|
private static final int MAX_COLLECTION_AGE_DAYS = 30;
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// POST — Collect cash for a share account
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
@POST
|
||||||
|
@Transactional
|
||||||
|
public Response collect(final String body) {
|
||||||
|
|
||||||
|
final AppUser currentUser = this.context.authenticatedUser();
|
||||||
|
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
|
||||||
|
|
||||||
|
// ── Parse request ──────────────────────────────────────────────────
|
||||||
|
if (body == null || body.isBlank()) {
|
||||||
|
throw new PlatformDataIntegrityException(
|
||||||
|
"error.msg.sharecollection.request.empty",
|
||||||
|
"Request body is required.");
|
||||||
|
}
|
||||||
|
final JsonObject json;
|
||||||
|
try {
|
||||||
|
json = JsonParser.parseString(body).getAsJsonObject();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new PlatformDataIntegrityException(
|
||||||
|
"error.msg.sharecollection.request.invalid",
|
||||||
|
"Request body must be valid JSON.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long shareAccountId = getLong(json, "shareAccountId");
|
||||||
|
final String transactionDateStr = getString(json, "transactionDate");
|
||||||
|
final BigDecimal shareAmount = getBigDecimal(json, "shareAmount", "shareAmount");
|
||||||
|
final BigDecimal chargeAmountRaw = getBigDecimal(json, "chargeAmount", "chargeAmount");
|
||||||
|
final String note = sanitiseNote(getString(json, "note"));
|
||||||
|
|
||||||
|
// ── Validate inputs ────────────────────────────────────────────────
|
||||||
|
if (shareAccountId == null) {
|
||||||
|
throw new PlatformDataIntegrityException(
|
||||||
|
"error.msg.sharecollection.accountid.required",
|
||||||
|
"shareAccountId is required.");
|
||||||
|
}
|
||||||
|
if (shareAmount.compareTo(BigDecimal.ZERO) < 0) {
|
||||||
|
throw new PlatformDataIntegrityException(
|
||||||
|
"error.msg.sharecollection.shareamount.negative",
|
||||||
|
"shareAmount cannot be negative.");
|
||||||
|
}
|
||||||
|
final BigDecimal safeCharge = chargeAmountRaw.compareTo(BigDecimal.ZERO) > 0
|
||||||
|
? chargeAmountRaw : BigDecimal.ZERO;
|
||||||
|
final BigDecimal totalAmount = shareAmount.add(safeCharge);
|
||||||
|
if (totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
throw new PlatformDataIntegrityException(
|
||||||
|
"error.msg.sharecollection.total.zero",
|
||||||
|
"Total collection amount must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final LocalDate entryDate;
|
||||||
|
try {
|
||||||
|
entryDate = transactionDateStr != null
|
||||||
|
? LocalDate.parse(transactionDateStr) : LocalDate.now();
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
throw new PlatformDataIntegrityException(
|
||||||
|
"error.msg.sharecollection.date.invalid",
|
||||||
|
"transactionDate must be in YYYY-MM-DD format. Received: " + transactionDateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load account + client details ──────────────────────────────────
|
||||||
|
final Map<String, Object> acct = loadAccount(shareAccountId);
|
||||||
|
|
||||||
|
// ── Account must be in 'Submitted and pending approval' ───────────
|
||||||
|
final int statusEnum = toInt(acct.get("status_enum"));
|
||||||
|
if (statusEnum != STATUS_SUBMITTED_PENDING) {
|
||||||
|
throw new PlatformDataIntegrityException(
|
||||||
|
"error.msg.sharecollection.account.invalid.status",
|
||||||
|
"Cash collection is only allowed for accounts in 'Submitted and pending approval' "
|
||||||
|
+ "status. Account " + acct.get("account_no") + " has status code " + statusEnum + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Prevent double collection ──────────────────────────────────────
|
||||||
|
if (Boolean.TRUE.equals(acct.get("cashier_collected"))) {
|
||||||
|
throw new PlatformDataIntegrityException(
|
||||||
|
"error.msg.sharecollection.already.collected",
|
||||||
|
"Cash has already been collected for share account "
|
||||||
|
+ acct.get("account_no") + ". Re-collection is not allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long officeId = toLong(acct.get("office_id"));
|
||||||
|
final Long productId = toLong(acct.get("product_id"));
|
||||||
|
final String currencyCode = (String) acct.get("currency_code");
|
||||||
|
final String clientName = (String) acct.get("client_name");
|
||||||
|
final String accountNo = (String) acct.get("account_no");
|
||||||
|
|
||||||
|
// ── Resolve GL accounts from product mapping ───────────────────────
|
||||||
|
final Long glReference = resolveGl(productId, FA_SHARES_REFERENCE);
|
||||||
|
final Long glSuspense = resolveGl(productId, FA_SHARES_SUSPENSE);
|
||||||
|
final Long glIncome = resolveGl(productId, FA_INCOME_FROM_FEES);
|
||||||
|
|
||||||
|
if (glReference == null) {
|
||||||
|
throw new PlatformDataIntegrityException(
|
||||||
|
"error.msg.sharecollection.gl.reference.notmapped",
|
||||||
|
"Share Reference (Cash at Vault) GL is not mapped for product " + productId
|
||||||
|
+ ". Configure Share Product accounting mappings first.");
|
||||||
|
}
|
||||||
|
if (glSuspense == null) {
|
||||||
|
throw new PlatformDataIntegrityException(
|
||||||
|
"error.msg.sharecollection.gl.suspense.notmapped",
|
||||||
|
"Share Suspense GL is not mapped for product " + productId
|
||||||
|
+ ". Configure Share Product accounting mappings first.");
|
||||||
|
}
|
||||||
|
if (safeCharge.compareTo(BigDecimal.ZERO) > 0 && glIncome == null) {
|
||||||
|
throw new PlatformDataIntegrityException(
|
||||||
|
"error.msg.sharecollection.gl.income.notmapped",
|
||||||
|
"Income from Fees GL is not mapped for product " + productId
|
||||||
|
+ " but charge amount " + safeCharge + " is present. "
|
||||||
|
+ "Configure Share Product accounting mappings first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generate unique transaction ID ─────────────────────────────────
|
||||||
|
final String txnId = "SC-" + shareAccountId + "-" + System.currentTimeMillis();
|
||||||
|
final long userId = currentUser.getId();
|
||||||
|
final String collectorName = currentUser.getUsername();
|
||||||
|
final LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
|
// ── Post GL journal entries ────────────────────────────────────────
|
||||||
|
// Dr Cash at Vault (SHARES_REFERENCE) = totalAmount
|
||||||
|
insertEntry(officeId, glReference, DEBIT, totalAmount, txnId,
|
||||||
|
entryDate, shareAccountId, userId, now, currencyCode,
|
||||||
|
"Share collection received for " + accountNo
|
||||||
|
+ (note != null ? " | " + note : ""));
|
||||||
|
|
||||||
|
// Cr Share Sundry (SHARES_SUSPENSE) = shareAmount
|
||||||
|
insertEntry(officeId, glSuspense, CREDIT, shareAmount, txnId,
|
||||||
|
entryDate, shareAccountId, userId, now, currencyCode,
|
||||||
|
"Share amount held in suspense - " + accountNo);
|
||||||
|
|
||||||
|
// Cr Income from Fees = chargeAmount (only if > 0)
|
||||||
|
if (safeCharge.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
insertEntry(officeId, glIncome, CREDIT, safeCharge, txnId,
|
||||||
|
entryDate, shareAccountId, userId, now, currencyCode,
|
||||||
|
"Charge collected upfront for " + accountNo);
|
||||||
|
|
||||||
|
// Mark charges paid → activation will skip duplicate charge GL entry
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE m_share_account_charge "
|
||||||
|
+ "SET is_paid_derived = true, "
|
||||||
|
+ " amount_paid_derived = amount, "
|
||||||
|
+ " amount_outstanding_derived = 0 "
|
||||||
|
+ "WHERE account_id = ? AND is_active = true AND is_paid_derived = false",
|
||||||
|
shareAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flip cashier_collected flag ────────────────────────────────────
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE m_share_account SET cashier_collected = true WHERE id = ?",
|
||||||
|
shareAccountId);
|
||||||
|
|
||||||
|
// ── Insert audit / validation record ──────────────────────────────
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO m_share_cashier_collection "
|
||||||
|
+ "(share_account_id, collected_by_user, collected_by_name, collection_date, "
|
||||||
|
+ " share_amount, charge_amount, total_amount, gl_transaction_id, "
|
||||||
|
+ " office_id, currency_code, note) "
|
||||||
|
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
shareAccountId, userId, collectorName,
|
||||||
|
java.sql.Date.valueOf(entryDate),
|
||||||
|
shareAmount, safeCharge, totalAmount,
|
||||||
|
txnId, officeId, currencyCode, note);
|
||||||
|
|
||||||
|
// ── Build GL entries summary for response ──────────────────────────
|
||||||
|
final List<Map<String, Object>> glEntries = new ArrayList<>();
|
||||||
|
glEntries.add(glEntryMap(resolveGlName(glReference), "Dr", totalAmount, currencyCode));
|
||||||
|
glEntries.add(glEntryMap(resolveGlName(glSuspense), "Cr", shareAmount, currencyCode));
|
||||||
|
if (safeCharge.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
glEntries.add(glEntryMap(resolveGlName(glIncome), "Cr", safeCharge, currencyCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Response ───────────────────────────────────────────────────────
|
||||||
|
final Map<String, Object> resp = new LinkedHashMap<>();
|
||||||
|
resp.put("transactionId", txnId);
|
||||||
|
resp.put("shareAccountId", shareAccountId);
|
||||||
|
resp.put("accountNo", accountNo);
|
||||||
|
resp.put("clientName", clientName);
|
||||||
|
resp.put("collectionDate", entryDate.toString());
|
||||||
|
resp.put("collectedBy", collectorName);
|
||||||
|
resp.put("shareAmount", shareAmount);
|
||||||
|
resp.put("chargeAmount", safeCharge);
|
||||||
|
resp.put("totalAmount", totalAmount);
|
||||||
|
resp.put("currencyCode", currencyCode);
|
||||||
|
resp.put("glEntries", glEntries);
|
||||||
|
|
||||||
|
return Response.ok(gson.toJson(resp)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// GET — Check collection status for a share account
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
@GET
|
||||||
|
@Path("/{shareAccountId}")
|
||||||
|
public Response getStatus(@PathParam("shareAccountId") final Long shareAccountId) {
|
||||||
|
this.context.authenticatedUser();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final Map<String, Object> rec = jdbcTemplate.queryForMap(
|
||||||
|
"SELECT scc.share_account_id, sa.account_no, "
|
||||||
|
+ " mc.display_name AS client_name, "
|
||||||
|
+ " scc.collected_by_name, scc.collection_date, "
|
||||||
|
+ " scc.share_amount, scc.charge_amount, scc.total_amount, "
|
||||||
|
+ " scc.gl_transaction_id, scc.currency_code, scc.note "
|
||||||
|
+ "FROM m_share_cashier_collection scc "
|
||||||
|
+ "JOIN m_share_account sa ON sa.id = scc.share_account_id "
|
||||||
|
+ "JOIN m_client mc ON mc.id = sa.client_id "
|
||||||
|
+ "WHERE scc.share_account_id = ?",
|
||||||
|
shareAccountId);
|
||||||
|
|
||||||
|
final Map<String, Object> resp = new LinkedHashMap<>();
|
||||||
|
resp.put("collected", true);
|
||||||
|
resp.put("shareAccountId", shareAccountId);
|
||||||
|
resp.put("accountNo", rec.get("account_no"));
|
||||||
|
resp.put("clientName", rec.get("client_name"));
|
||||||
|
resp.put("collectedBy", rec.get("collected_by_name"));
|
||||||
|
resp.put("collectionDate", rec.get("collection_date") != null
|
||||||
|
? rec.get("collection_date").toString() : null);
|
||||||
|
resp.put("shareAmount", rec.get("share_amount"));
|
||||||
|
resp.put("chargeAmount", rec.get("charge_amount"));
|
||||||
|
resp.put("totalAmount", rec.get("total_amount"));
|
||||||
|
resp.put("transactionId", rec.get("gl_transaction_id"));
|
||||||
|
resp.put("currencyCode", rec.get("currency_code"));
|
||||||
|
resp.put("note", rec.get("note"));
|
||||||
|
return Response.ok(gson.toJson(resp)).build();
|
||||||
|
|
||||||
|
} catch (EmptyResultDataAccessException e) {
|
||||||
|
final Map<String, Object> resp = new LinkedHashMap<>();
|
||||||
|
resp.put("collected", false);
|
||||||
|
resp.put("shareAccountId", shareAccountId);
|
||||||
|
return Response.ok(gson.toJson(resp)).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// Private helpers
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
private Map<String, Object> loadAccount(final Long shareAccountId) {
|
||||||
|
try {
|
||||||
|
return jdbcTemplate.queryForMap(
|
||||||
|
"SELECT sa.id, sa.account_no, sa.status_enum, sa.product_id, "
|
||||||
|
+ " sa.cashier_collected, sa.currency_code, "
|
||||||
|
+ " mc.office_id, mc.display_name AS client_name "
|
||||||
|
+ "FROM m_share_account sa "
|
||||||
|
+ "JOIN m_client mc ON mc.id = sa.client_id "
|
||||||
|
+ "WHERE sa.id = ?",
|
||||||
|
shareAccountId);
|
||||||
|
} catch (EmptyResultDataAccessException e) {
|
||||||
|
throw new PlatformDataIntegrityException(
|
||||||
|
"error.msg.sharecollection.account.notfound",
|
||||||
|
"Share account with id " + shareAccountId + " does not exist.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveGl(final Long productId, final int financialAccountType) {
|
||||||
|
try {
|
||||||
|
return jdbcTemplate.queryForObject(
|
||||||
|
"SELECT gl_account_id FROM acc_product_mapping "
|
||||||
|
+ "WHERE product_id = ? AND product_type = ? AND financial_account_type = ?",
|
||||||
|
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
|
||||||
|
} catch (IncorrectResultSizeDataAccessException e) {
|
||||||
|
// covers both EmptyResultDataAccessException (0 rows) and multiple rows
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveGlName(final Long glAccountId) {
|
||||||
|
if (glAccountId == null) return "Unknown GL";
|
||||||
|
try {
|
||||||
|
return jdbcTemplate.queryForObject(
|
||||||
|
"SELECT name FROM acc_gl_account WHERE id = ?",
|
||||||
|
String.class, glAccountId);
|
||||||
|
} catch (EmptyResultDataAccessException e) {
|
||||||
|
return "GL-" + glAccountId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertEntry(final Long officeId, final Long glAccountId,
|
||||||
|
final int typeEnum, final BigDecimal amount, final String txnId,
|
||||||
|
final LocalDate entryDate, final Long shareAccountId,
|
||||||
|
final long userId, final LocalDateTime now, final String currencyCode,
|
||||||
|
final String description) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO acc_gl_journal_entry "
|
||||||
|
+ "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
|
||||||
|
+ " entry_date, type_enum, amount, description, entity_id, "
|
||||||
|
+ " created_by, last_modified_by, created_date, lastmodified_date, "
|
||||||
|
+ " is_running_balance_calculated, office_running_balance, "
|
||||||
|
+ " organization_running_balance, submitted_on_date, transaction_date, "
|
||||||
|
+ " created_on_utc, last_modified_on_utc) "
|
||||||
|
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
|
||||||
|
glAccountId, officeId, currencyCode, txnId,
|
||||||
|
java.sql.Date.valueOf(entryDate), typeEnum, amount, description,
|
||||||
|
shareAccountId, // entity_id — links entry to the share account
|
||||||
|
userId, userId, now, now,
|
||||||
|
java.sql.Date.valueOf(entryDate),
|
||||||
|
java.sql.Date.valueOf(entryDate),
|
||||||
|
now.atOffset(ZoneOffset.UTC),
|
||||||
|
now.atOffset(ZoneOffset.UTC));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> glEntryMap(final String account, final String type,
|
||||||
|
final BigDecimal amount, final String currency) {
|
||||||
|
final Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("account", account);
|
||||||
|
m.put("type", type);
|
||||||
|
m.put("amount", amount);
|
||||||
|
m.put("currency", currency);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sanitiseNote(final String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) return null;
|
||||||
|
final String trimmed = raw.trim();
|
||||||
|
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long toLong(final Object o) {
|
||||||
|
return o instanceof Number ? ((Number) o).longValue() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int toInt(final Object o) {
|
||||||
|
return o instanceof Number ? ((Number) o).intValue() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long getLong(final JsonObject json, final String key) {
|
||||||
|
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsLong() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal getBigDecimal(final JsonObject json, final String key,
|
||||||
|
final String fieldLabel) {
|
||||||
|
if (!json.has(key) || json.get(key).isJsonNull()) return BigDecimal.ZERO;
|
||||||
|
try {
|
||||||
|
return json.get(key).getAsBigDecimal();
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
throw new PlatformDataIntegrityException(
|
||||||
|
"error.msg.sharecollection.field.invalid.number",
|
||||||
|
"Field '" + fieldLabel + "' must be a valid number.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getString(final JsonObject json, final String key) {
|
||||||
|
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
-4
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.shareaccounts.service;
|
|||||||
|
|
||||||
import jakarta.persistence.PersistenceException;
|
import jakarta.persistence.PersistenceException;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.Date;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -27,6 +28,7 @@ import java.util.HashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||||
@@ -55,6 +57,8 @@ import org.apache.fineract.portfolio.shareaccounts.serialization.ShareAccountDat
|
|||||||
import org.apache.fineract.portfolio.shareproducts.domain.ShareProduct;
|
import org.apache.fineract.portfolio.shareproducts.domain.ShareProduct;
|
||||||
import org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepositoryWrapper;
|
import org.apache.fineract.portfolio.shareproducts.domain.ShareProductRepositoryWrapper;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.orm.jpa.JpaSystemException;
|
import org.springframework.orm.jpa.JpaSystemException;
|
||||||
|
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -74,6 +78,8 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
|||||||
|
|
||||||
private final NoteRepository noteRepository;
|
private final NoteRepository noteRepository;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
private final BusinessEventNotifierService businessEventNotifierService;
|
private final BusinessEventNotifierService businessEventNotifierService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -82,8 +88,12 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
|||||||
ShareAccount account = this.accountDataSerializer.validateAndCreate(jsonCommand);
|
ShareAccount account = this.accountDataSerializer.validateAndCreate(jsonCommand);
|
||||||
this.shareAccountRepository.saveAndFlush(account);
|
this.shareAccountRepository.saveAndFlush(account);
|
||||||
generateAccountNumber(account);
|
generateAccountNumber(account);
|
||||||
journalEntryWritePlatformService.createJournalEntriesForShares(
|
// GL entries are intentionally NOT posted at submission time.
|
||||||
populateJournalEntries(account, account.getPendingForApprovalSharePurchaseTransactions()));
|
// Cash is physically collected by the cashier AFTER submission via the
|
||||||
|
// /cashier/sharecollection endpoint, which posts:
|
||||||
|
// Dr Cash at Vault → Cr Share Sundry (share amount)
|
||||||
|
// Cr Income from Fees (charge amount)
|
||||||
|
// Posting GL here would cause a duplicate entry when cashier collects.
|
||||||
|
|
||||||
businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountCreateBusinessEvent(account));
|
businessEventNotifierService.notifyPostBusinessEvent(new ShareAccountCreateBusinessEvent(account));
|
||||||
|
|
||||||
@@ -345,13 +355,60 @@ public class ShareAccountWritePlatformServiceJpaRepositoryImpl implements ShareA
|
|||||||
public CommandProcessingResult activateShareAccount(Long accountId, JsonCommand jsonCommand) {
|
public CommandProcessingResult activateShareAccount(Long accountId, JsonCommand jsonCommand) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// ── Validate: verified cashier collection record must exist ────────
|
||||||
|
// Checks m_share_cashier_collection for a record where:
|
||||||
|
// 1. share_account_id = exactly this account (no cross-account match)
|
||||||
|
// 2. total_amount > 0 (real cash was received, not a zero entry)
|
||||||
|
// 3. collection_date within MAX_COLLECTION_AGE_DAYS (not a stale record)
|
||||||
|
final int maxAgeDays = 30;
|
||||||
|
final Date oldestValidDate = Date.valueOf(LocalDate.now().minusDays(maxAgeDays));
|
||||||
|
|
||||||
|
final Map<String, Object> collectionRecord;
|
||||||
|
try {
|
||||||
|
collectionRecord = jdbcTemplate.queryForMap(
|
||||||
|
"SELECT share_account_id, collected_by_name, collection_date, "
|
||||||
|
+ " total_amount, gl_transaction_id "
|
||||||
|
+ "FROM m_share_cashier_collection "
|
||||||
|
+ "WHERE share_account_id = ? "
|
||||||
|
+ " AND total_amount > 0 "
|
||||||
|
+ " AND collection_date >= ?",
|
||||||
|
accountId, oldestValidDate);
|
||||||
|
} catch (EmptyResultDataAccessException e) {
|
||||||
|
throw new org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException(
|
||||||
|
"error.msg.share.activation.cashier.collection.required",
|
||||||
|
"Share account " + accountId + " cannot be activated: "
|
||||||
|
+ "no cashier cash-collection record found within the last "
|
||||||
|
+ maxAgeDays + " days. "
|
||||||
|
+ "Please complete the Share Collection step in the Cashier menu first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query BEFORE activation which charges were already paid via cashier collection.
|
||||||
|
// Cashier collection sets is_paid_derived = true; activation would otherwise
|
||||||
|
// post a duplicate Dr Cash → Cr Income entry for those same charges.
|
||||||
|
final Set<Long> cashierPaidChargeIds = new HashSet<>(jdbcTemplate.queryForList(
|
||||||
|
"SELECT id FROM m_share_account_charge "
|
||||||
|
+ "WHERE account_id = ? AND is_paid_derived = true",
|
||||||
|
Long.class, accountId));
|
||||||
|
|
||||||
ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
|
ShareAccount account = this.shareAccountRepository.findOneWithNotFoundDetection(accountId);
|
||||||
Map<String, Object> changes = this.accountDataSerializer.validateAndActivate(jsonCommand, account);
|
Map<String, Object> changes = this.accountDataSerializer.validateAndActivate(jsonCommand, account);
|
||||||
if (!changes.isEmpty()) {
|
if (!changes.isEmpty()) {
|
||||||
this.shareAccountRepository.save(account);
|
this.shareAccountRepository.save(account);
|
||||||
}
|
}
|
||||||
this.journalEntryWritePlatformService
|
|
||||||
.createJournalEntriesForShares(populateJournalEntries(account, account.getChargeTransactions()));
|
// Filter out charge transactions whose charges were already collected
|
||||||
|
// by the cashier — prevents duplicate GL entries on activation.
|
||||||
|
Set<ShareAccountTransaction> chargesForGL = account.getChargeTransactions().stream()
|
||||||
|
.filter(txn -> txn.getChargesPaidBy() == null
|
||||||
|
|| txn.getChargesPaidBy().stream()
|
||||||
|
.noneMatch(cpb -> cashierPaidChargeIds.contains(cpb.getShareChargeId())))
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
if (!chargesForGL.isEmpty()) {
|
||||||
|
this.journalEntryWritePlatformService
|
||||||
|
.createJournalEntriesForShares(populateJournalEntries(account, chargesForGL));
|
||||||
|
}
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
.withCommandId(jsonCommand.commandId()) //
|
.withCommandId(jsonCommand.commandId()) //
|
||||||
.withEntityId(accountId) //
|
.withEntityId(accountId) //
|
||||||
|
|||||||
+2
-1
@@ -115,9 +115,10 @@ public class ShareAccountsConfiguration {
|
|||||||
ShareAccountRepositoryWrapper shareAccountRepository, ShareProductRepositoryWrapper shareProductRepository,
|
ShareAccountRepositoryWrapper shareAccountRepository, ShareProductRepositoryWrapper shareProductRepository,
|
||||||
AccountNumberGenerator accountNumberGenerator, AccountNumberFormatRepositoryWrapper accountNumberFormatRepository,
|
AccountNumberGenerator accountNumberGenerator, AccountNumberFormatRepositoryWrapper accountNumberFormatRepository,
|
||||||
JournalEntryWritePlatformService journalEntryWritePlatformService, NoteRepository noteRepository,
|
JournalEntryWritePlatformService journalEntryWritePlatformService, NoteRepository noteRepository,
|
||||||
|
org.springframework.jdbc.core.JdbcTemplate jdbcTemplate,
|
||||||
BusinessEventNotifierService businessEventNotifierService) {
|
BusinessEventNotifierService businessEventNotifierService) {
|
||||||
return new ShareAccountWritePlatformServiceJpaRepositoryImpl(accountDataSerializer, shareAccountRepository, shareProductRepository,
|
return new ShareAccountWritePlatformServiceJpaRepositoryImpl(accountDataSerializer, shareAccountRepository, shareProductRepository,
|
||||||
accountNumberGenerator, accountNumberFormatRepository, journalEntryWritePlatformService, noteRepository,
|
accountNumberGenerator, accountNumberFormatRepository, journalEntryWritePlatformService, noteRepository,
|
||||||
businessEventNotifierService);
|
jdbcTemplate, businessEventNotifierService);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user