diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/agent/domain/Agent.java b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/domain/Agent.java index 26ac6330f..f6c8f01c7 100644 --- a/fineract-core/src/main/java/org/apache/fineract/organisation/agent/domain/Agent.java +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/agent/domain/Agent.java @@ -28,6 +28,7 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.OrderBy; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; @@ -61,6 +62,15 @@ import org.apache.fineract.portfolio.client.domain.Client; @Table(name = "m_dds_agent", uniqueConstraints = { @UniqueConstraint(columnNames = { "agent_code" }, name = "agent_code_UNIQUE") }) public class Agent extends AbstractAuditableCustom { + /** + * Optimistic lock, as a savings account has: two saves of the same row from different requests - + * two collections at once, or a collection during a posting run - cannot both win; the second is + * refused and retried instead of quietly overwriting the first one's balance. + */ + @Version + @Column(name = "version", nullable = false) + private Long version; + @ManyToOne @JoinColumn(name = "client_id", nullable = false) private Client client; diff --git a/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/domain/CommissionSlab.java b/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/domain/CommissionSlab.java index 9b32c3055..58f764c2f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/domain/CommissionSlab.java +++ b/fineract-core/src/main/java/org/apache/fineract/organisation/commissionslab/domain/CommissionSlab.java @@ -69,6 +69,16 @@ public class CommissionSlab extends AbstractAuditableCustom { * Corrects this slab, reporting only what actually changed so the audit trail records the * correction rather than a wholesale rewrite. */ + /** Moves where this slab starts - used when the slab before it is corrected, so the two still meet. */ + public void startAt(final BigDecimal from) { + this.slabFrom = from; + } + + /** Moves where this slab ends - used when the slab after it is corrected, so the two still meet. */ + public void endAt(final BigDecimal to) { + this.slabTo = to; + } + public Map update(final JsonCommand command) { final Map actualChanges = new LinkedHashMap<>(4); diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/domain/BatchRun.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/domain/BatchRun.java index 2332e62da..34938d8c9 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/domain/BatchRun.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/batch/domain/BatchRun.java @@ -25,6 +25,7 @@ import jakarta.persistence.FetchType; import jakarta.persistence.OneToMany; import jakarta.persistence.OrderBy; import jakarta.persistence.Table; +import jakarta.persistence.Version; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; @@ -45,6 +46,15 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom; @Table(name = "m_dds_batch_run") public class BatchRun extends AbstractAuditableCustom { + /** + * Optimistic lock, as a savings account has: two saves of the same row from different requests - + * two collections at once, or a collection during a posting run - cannot both win; the second is + * refused and retried instead of quietly overwriting the first one's balance. + */ + @Version + @Column(name = "version", nullable = false) + private Long version; + @Column(name = "job_type", length = 40, nullable = false) private String jobType; @@ -87,6 +97,14 @@ public class BatchRun extends AbstractAuditableCustom { @Column(name = "rows_posted", nullable = false) private Integer rowsPosted = 0; + /** + * What the run actually moved, beside totalAmount - what it was generated to move. They differ when + * rows were passed over at posting (the money had already been paid by another run), and the run + * says so rather than reading as though the whole total went through. + */ + @Column(name = "posted_amount", scale = 6, precision = 19) + private BigDecimal postedAmount; + /** Why a run stopped, when it did. A background run has nowhere else to say so. */ @Column(name = "failure_reason", length = 500) private String failureReason; @@ -155,6 +173,11 @@ public class BatchRun extends AbstractAuditableCustom { this.rowsPosted = posted; } + public void recordPosted(final int posted, final BigDecimal amount) { + this.rowsPosted = posted; + this.postedAmount = amount; + } + public void markPosted(final LocalDate postedOnDate) { this.status = STATUS_POSTED; this.postedOnDate = postedOnDate; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/domain/CollectionEntry.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/domain/CollectionEntry.java index 7f10c5aee..c3fdfdeba 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/domain/CollectionEntry.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/collection/domain/CollectionEntry.java @@ -23,6 +23,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.Version; import java.math.BigDecimal; import java.time.LocalDate; import lombok.Getter; @@ -49,6 +50,15 @@ import org.apache.fineract.portfolio.dds.party.domain.Party; @Table(name = "m_dds_collection_entry") public class CollectionEntry extends AbstractAuditableCustom { + /** + * Optimistic lock, as a savings account has: two saves of the same row from different requests - + * two collections at once, or a collection during a posting run - cannot both win; the second is + * refused and retried instead of quietly overwriting the first one's balance. + */ + @Version + @Column(name = "version", nullable = false) + private Long version; + /** Waiting for a clerk to count the cash behind it. */ public static final String PENDING = "PENDING"; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/data/PartyData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/data/PartyData.java index 27aeb44d3..7cd7a6130 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/data/PartyData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/data/PartyData.java @@ -178,5 +178,11 @@ public final class PartyData implements Serializable { private final String nomineeSplitMode; private final String status; private final LocalDate closeDate; + /** The product by id, the account currency, and who opened (made operative) and closed it - as a savings account shows. */ + private final Long accountTypeId; + private final String currencyCode; + private final LocalDate activatedOnDate; + private final String activatedByUsername; + private final String closedByUsername; private final Collection nominees; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/domain/Party.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/domain/Party.java index 25b269baa..27208303f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/domain/Party.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/party/domain/Party.java @@ -28,6 +28,7 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.OrderBy; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; import java.math.BigDecimal; import java.math.MathContext; import java.math.RoundingMode; @@ -60,6 +61,15 @@ import org.apache.fineract.portfolio.client.domain.Client; @Table(name = "m_dds_party", uniqueConstraints = { @UniqueConstraint(columnNames = { "party_code" }, name = "party_code_UNIQUE") }) public class Party extends AbstractAuditableCustom { + /** + * Optimistic lock, as a savings account has: two saves of the same row from different requests - + * two collections at once, or a collection during a posting run - cannot both win; the second is + * refused and retried instead of quietly overwriting the first one's balance. + */ + @Version + @Column(name = "version", nullable = false) + private Long version; + /** DDS accounts carry a flat interest rate unless the officer explicitly overrides it at creation. */ private static final BigDecimal DEFAULT_INTEREST_RATE = new BigDecimal("2"); @@ -304,6 +314,29 @@ public class Party extends AbstractAuditableCustom { @Column(name = "close_date") private LocalDate closeDate; + /** + * The DDS product the account is on, by id - subType carries its code for the screens, but a code + * can be renamed and an id cannot, so this is what ties the account to its product's terms and + * ledger accounts (as m_savings_account.product_id does). + */ + @Column(name = "account_type_id") + private Long accountTypeId; + + /** The account's currency, as a savings account records its own - set from the bank's currency on opening. */ + @Column(name = "currency_code", length = 3) + private String currencyCode; + + /** When the account became operative, and who made it so - the checker, where the opening was approved. */ + @Column(name = "activatedon_date") + private LocalDate activatedOnDate; + + @Column(name = "activatedon_userid") + private Long activatedByUserId; + + /** Who closed the account, beside closeDate - when it was closed. */ + @Column(name = "closedon_userid") + private Long closedByUserId; + @OneToMany(mappedBy = "party", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @OrderBy("id") private List nominees = new ArrayList<>(); @@ -492,10 +525,13 @@ public class Party extends AbstractAuditableCustom { this.savingsAccountId = savingsAccountId; } - /** Numbers the account from its id, the same way 0514 numbered the accounts already open. */ - public void assignAccountNo() { - if (this.accountNo == null && getId() != null) { - this.accountNo = "DDS" + String.format("%09d", getId()); + /** + * Numbers the account from the DDS account serial (m_dds_account_number_serial), not from its id: an + * id is spent by every save that rolls back, and the bank's account numbers must run without gaps. + */ + public void assignAccountNo(final long serial) { + if (this.accountNo == null) { + this.accountNo = "DDS" + String.format("%09d", serial); } } @@ -539,9 +575,22 @@ public class Party extends AbstractAuditableCustom { * is settled to zero (a full Withdraw Deposite/payout) before it is closed, not closed with money * still owed to it. */ - public void close(final LocalDate closeDate) { + public void close(final LocalDate closeDate, final Long closedBy) { this.status = STATUS_CLOSED; this.closeDate = closeDate; + this.closedByUserId = closedBy; + } + + /** Ties the account to its product by id; called whenever subType is set or changed. */ + public void linkProduct(final Long productId) { + this.accountTypeId = productId; + } + + /** Stamps the opening: the account's currency, and the day and user that made it operative. */ + public void recordActivation(final String currency, final LocalDate on, final Long by) { + this.currencyCode = currency; + this.activatedOnDate = on; + this.activatedByUserId = by; } /** diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/data/PartyTransactionData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/data/PartyTransactionData.java index d20ae4235..72f3394cd 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/data/PartyTransactionData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/data/PartyTransactionData.java @@ -22,6 +22,7 @@ import java.io.Serial; import java.io.Serializable; import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; import lombok.Builder; import lombok.Getter; @@ -51,4 +52,15 @@ public final class PartyTransactionData implements Serializable { private final BigDecimal balanceAfterTransaction; /** The DDS voucher this line moved under, e.g. DDS/29092026/0001; empty on lines from before vouchers. */ private final String voucherNo; + /** The branch the line is booked at, the business day it was recorded, and the journal it was booked under. */ + private final Long officeId; + private final String officeName; + private final LocalDate submittedOnDate; + private final String journalTransactionId; + /** The line's own unique reference (m_dds_party_transaction.ref_no). */ + private final String refNo; + /** Who keyed the line and when, as a savings transaction shows it. */ + private final String createdByUsername; + private final LocalDateTime createdDate; + private final String agentName; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/domain/PartyTransaction.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/domain/PartyTransaction.java index 9b4bb3322..fde98c997 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/domain/PartyTransaction.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/transaction/domain/PartyTransaction.java @@ -23,11 +23,14 @@ import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.PostPersist; +import jakarta.persistence.PrePersist; import jakarta.persistence.Table; import java.math.BigDecimal; import java.time.LocalDate; +import java.util.UUID; import lombok.Getter; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.organisation.agent.domain.Agent; import org.apache.fineract.portfolio.dds.party.domain.Party; import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope; @@ -87,6 +90,36 @@ public class PartyTransaction extends AbstractAuditableCustom { @Column(name = "balance_after_transaction", scale = 6, precision = 19, nullable = false) private BigDecimal balanceAfterTransaction; + /** The branch the line is booked at - the party's, or its agent's for a line with no party - as savings rows carry. */ + @Column(name = "office_id") + private Long officeId; + + /** + * The business day the line was recorded, beside transactionDate - the day the money moved. They + * differ when a collection is applied from a cashier's receipt days after it was collected. + */ + @Column(name = "submitted_on_date") + private LocalDate submittedOnDate; + + /** + * The acc_gl_journal_entry.transaction_id the line was booked under, so a passbook line and its + * journal can be found from each other. Null for a line that moves nothing on the books (a transfer + * between two customers owed on the same account). + */ + @Column(name = "journal_transaction_id", length = 100) + private String journalTransactionId; + + /** + * The line's own reference, unique across DDS, as a savings transaction carries ref_no: what a + * statement, a complaint or a reconciliation quotes to name exactly one line. + */ + @Column(name = "ref_no", length = 40, nullable = false, unique = true) + private String refNo; + + /** The voucher the line was printed on - stamped by DdsVoucherService once the operation's voucher is raised. */ + @Column(name = "voucher_no", length = 30, insertable = false, updatable = false) + private String voucherNo; + protected PartyTransaction() { // } @@ -108,6 +141,31 @@ public class PartyTransaction extends AbstractAuditableCustom { this.tokenNo = tokenNo; this.description = description; this.balanceAfterTransaction = balanceAfterTransaction; + if (party != null && party.getOffice() != null) { + this.officeId = party.getOffice().getId(); + } else if (agent != null && agent.getOffice() != null) { + this.officeId = agent.getOffice().getId(); + } else if (agent != null && agent.getClient() != null && agent.getClient().getOffice() != null) { + // An agent saved without a branch is still a client of one. + this.officeId = agent.getClient().getOffice().getId(); + } + this.submittedOnDate = DateUtils.getBusinessLocalDate(); + } + + /** Names the journal this line was booked under, where the caller knows it better than the operation's last one. */ + public PartyTransaction linkJournal(final String transactionId) { + this.journalTransactionId = transactionId; + return this; + } + + @PrePersist + private void linkToJournal() { + if (this.refNo == null) { + this.refNo = "DDST" + UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase(); + } + if (this.journalTransactionId == null) { + this.journalTransactionId = DdsVoucherScope.lastLedgerTransaction(); + } } /** diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/voucher/DdsVoucherScope.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/voucher/DdsVoucherScope.java index 96552bea8..0a5360390 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/voucher/DdsVoucherScope.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/dds/voucher/DdsVoucherScope.java @@ -48,6 +48,8 @@ public final class DdsVoucherScope { private final Set ledgerTransactionIds = new LinkedHashSet<>(); private final List partyTransactionIds = new ArrayList<>(); private final List extraLines = new ArrayList<>(); + /** The journal most recently written in this operation - what the next passbook line saved belongs to. */ + private String lastLedgerTransactionId; private DdsVoucherScope() {} @@ -72,6 +74,24 @@ public final class DdsVoucherScope { final DdsVoucherScope scope = CURRENT.get(); if (scope != null && transactionId != null) { scope.ledgerTransactionIds.add(transactionId); + scope.lastLedgerTransactionId = transactionId; + } + } + + /** + * The journal the passbook line being saved now belongs to: every DDS movement writes its journal + * first and its passbook line straight after, so it is the last one written in this operation. + */ + public static String lastLedgerTransaction() { + final DdsVoucherScope scope = CURRENT.get(); + return scope == null ? null : scope.lastLedgerTransactionId; + } + + /** For a movement that writes no journal, so its passbook line does not claim the previous one's. */ + public static void forgetLastLedgerTransaction() { + final DdsVoucherScope scope = CURRENT.get(); + if (scope != null) { + scope.lastLedgerTransactionId = null; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/serialization/CommissionSlabCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/serialization/CommissionSlabCommandFromApiJsonDeserializer.java index 485e8feeb..193ac3b83 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/serialization/CommissionSlabCommandFromApiJsonDeserializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/serialization/CommissionSlabCommandFromApiJsonDeserializer.java @@ -44,6 +44,8 @@ public final class CommissionSlabCommandFromApiJsonDeserializer { // A List, not a Set: isOneOfTheseStringValues only lowercases both sides in its List overload. private static final List SUPPORTED_SLAB_TYPES = List.of("ACCOUNT", "COMMISSION"); + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + private static final Set SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("slabs", "dateFormat", "locale")); private static final Set SUPPORTED_UPDATE_PARAMETERS = new HashSet<>( @@ -93,7 +95,7 @@ public final class CommissionSlabCommandFromApiJsonDeserializer { v.reset().parameterAtIndexArray("slabTo", i).value(extractBigDecimal(slabJson, "slabTo")).notNull() .zeroOrPositiveAmount(); v.reset().parameterAtIndexArray("commissionPercent", i).value(extractBigDecimal(slabJson, "commissionPercent")) - .notNull().zeroOrPositiveAmount(); + .notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED); } } @@ -134,7 +136,7 @@ public final class CommissionSlabCommandFromApiJsonDeserializer { if (this.fromApiJsonHelper.parameterExists("commissionPercent", element)) { v.reset().parameter("commissionPercent") .value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("commissionPercent", element)).notNull() - .zeroOrPositiveAmount(); + .zeroOrPositiveAmount().notGreaterThanMax(HUNDRED); } throwExceptionIfValidationWarningsExist(dataValidationErrors); diff --git a/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabWritePlatformServiceJpaRepositoryImpl.java index 850f07d06..1b2878c49 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/organisation/commissionslab/service/CommissionSlabWritePlatformServiceJpaRepositoryImpl.java @@ -21,16 +21,21 @@ package org.apache.fineract.organisation.commissionslab.service; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import jakarta.persistence.PersistenceException; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.exception.ErrorHandler; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab; import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository; import org.apache.fineract.organisation.commissionslab.exception.CommissionSlabNotFoundException; @@ -68,6 +73,14 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm slabs.add(slab); } + // The chart each type will have once these are added - what is stored plus this request - + // must still read as one unbroken scale from 0. + for (final String type : slabs.stream().map(CommissionSlab::getSlabType).distinct().toList()) { + final List chart = new ArrayList<>(this.commissionSlabRepository.findByType(type)); + chart.addAll(slabs.stream().filter(s -> s.getSlabType().equals(type)).toList()); + rejectBrokenChart(type, chart); + } + this.commissionSlabRepository.saveAllAndFlush(slabs); return new CommandProcessingResultBuilder() // @@ -93,6 +106,9 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm final CommissionSlab slab = this.commissionSlabRepository.findById(slabId) .orElseThrow(() -> new CommissionSlabNotFoundException(slabId)); + final String oldType = slab.getSlabType(); + final BigDecimal oldFrom = slab.getSlabFrom(); + final BigDecimal oldTo = slab.getSlabTo(); final Map changes = slab.update(command); // Re-checked after the change, against every other slab of its type. A correction can just as @@ -101,8 +117,38 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm if (!changes.isEmpty()) { final List others = this.commissionSlabRepository.findByType(slab.getSlabType()).stream() .filter(other -> !other.getId().equals(slab.getId())).toList(); + + // A boundary moved within the same chart takes its neighbour with it, so the two slabs still + // meet a paisa apart and the chart stays unbroken: 10,000 -> 12,000 on this slab's To moves + // the next slab's From from 10,000.01 to 12,000.01. + final List moved = new ArrayList<>(); + if (oldType.equals(slab.getSlabType())) { + for (final CommissionSlab other : others) { + if (slab.getSlabTo().compareTo(oldTo) != 0 && other.getSlabFrom().compareTo(oldTo.add(PAISA)) == 0) { + other.startAt(slab.getSlabTo().add(PAISA)); + moved.add(other); + } + if (slab.getSlabFrom().compareTo(oldFrom) != 0 && other.getSlabTo().compareTo(oldFrom.subtract(PAISA)) == 0) { + other.endAt(slab.getSlabFrom().subtract(PAISA)); + moved.add(other); + } + } + } + rejectIfOverlapping(slab, others); + final List chart = new ArrayList<>(others); + chart.add(slab); + rejectBrokenChart(slab.getSlabType(), chart); + if (!oldType.equals(slab.getSlabType())) { + // Moved to the other type: the chart it left must still be whole too. + rejectBrokenChart(oldType, this.commissionSlabRepository.findByType(oldType).stream() + .filter(other -> !other.getId().equals(slab.getId())).toList()); + } + this.commissionSlabRepository.saveAll(moved); this.commissionSlabRepository.saveAndFlush(slab); + if (!moved.isEmpty()) { + changes.put("neighboursAdjusted", moved.size()); + } } return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(slabId).with(changes).build(); @@ -116,6 +162,63 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm * explain it. Ranges are inclusive at both ends, so touching endpoints count as an overlap: a * slab ending at 50000 and the next starting at 50000 both match exactly 50000. */ + /** Collections are rupees and paise, so consecutive slabs meet one paisa apart. */ + private static final BigDecimal PAISA = new BigDecimal("0.01"); + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + + /** + * A chart the commission run can always read: every slab From <= To, a percentage of 0 to 100, money + * to the paisa, starting at 0, and each slab beginning exactly a paisa after the one before ends. + * + * The run matches from <= collection <= to and stops with "no commission slab" for an agent whose + * collection no slab covers - so a gap, or a chart starting above 0, is not a gentler setting, it is + * a run that fails. Refused here, when the chart is saved, rather than discovered on payday. + */ + private void rejectBrokenChart(final String type, final List chart) { + final List sorted = chart.stream().sorted(Comparator.comparing(CommissionSlab::getSlabFrom)).toList(); + final List errors = new ArrayList<>(); + for (int i = 0; i < sorted.size(); i++) { + final CommissionSlab slab = sorted.get(i); + final String range = plain(slab.getSlabFrom()) + " - " + plain(slab.getSlabTo()); + if (slab.getSlabFrom().compareTo(slab.getSlabTo()) > 0) { + errors.add(chartError("from.after.to", type + " slab " + range + ": From is more than To.", "slabFrom")); + } + if (slab.getSlabFrom().stripTrailingZeros().scale() > 2 || slab.getSlabTo().stripTrailingZeros().scale() > 2) { + errors.add(chartError("paise", type + " slab " + range + ": amounts can have at most two decimals (paise).", "slabFrom")); + } + if (slab.getCommissionPercent().signum() < 0 || slab.getCommissionPercent().compareTo(HUNDRED) > 0) { + errors.add(chartError("percent", type + " slab " + range + ": the percentage must be from 0 to 100.", + "commissionPercent")); + } + if (i == 0) { + if (slab.getSlabFrom().signum() != 0) { + errors.add(chartError("not.from.zero", type + " slabs must start at 0 - nothing covers 0 to " + + plain(slab.getSlabFrom().subtract(PAISA)) + ".", "slabFrom")); + } + } else { + final BigDecimal expected = sorted.get(i - 1).getSlabTo().add(PAISA); + if (slab.getSlabFrom().compareTo(expected) > 0) { + errors.add(chartError("gap", type + " slabs leave a gap: nothing covers " + plain(expected) + " to " + + plain(slab.getSlabFrom().subtract(PAISA)) + ". Start this slab at " + plain(expected) + ".", + "slabFrom")); + } + } + } + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException("error.msg.commissionslab.chart.broken", + "The " + type + " commission chart would not cover every collection: " + errors.get(0).getDefaultUserMessage(), + errors); + } + } + + private static ApiParameterError chartError(final String code, final String message, final String parameter) { + return ApiParameterError.parameterError("error.msg.commissionslab." + code, message, parameter); + } + + private static String plain(final BigDecimal value) { + return value.setScale(2, RoundingMode.HALF_UP).toPlainString(); + } + private void rejectIfOverlapping(final CommissionSlab candidate, final List existing) { for (final CommissionSlab other : existing) { if (!other.getSlabType().equals(candidate.getSlabType())) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsLedger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsLedger.java index c6ec15c6d..5bbdd887f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsLedger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsLedger.java @@ -270,24 +270,23 @@ public class DdsLedger { final String description) { final GLAccount owed; final Office office; + Long withdrawalId = null; + Long savingsAccountId = null; if (party.getSavingsAccountId() != null) { final SavingsAccount account = load(party.getSavingsAccountId()); - final boolean wasBlocked = unlockDebitsIfBlocked(account); - final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true); - this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags, - this.savingsAccountAssembler.getPivotConfigStatus()); - relockDebits(account, wasBlocked); + withdrawalId = withdraw(account, date, amount).getId(); + savingsAccountId = account.getId(); owed = controlAccountOf(account); office = account.office(); } else { owed = depositLiabilityOf(party); - office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice(); + office = officeOf(party); } creditCurrentAccount(currentAccountId, date, amount, description); - final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8); + final String transactionId = withdrawalId != null ? "S" + withdrawalId : partyLegId(party); writePair(office, organisationCurrency(), transactionId, date, amount, owed, currentAccountControl(currentAccountId), description, - null, null); + withdrawalId, savingsAccountId); } private static final String CURRENT_ACCOUNT_SQL = "select status_enum, account_balance_derived, office_id from m_current_account where id = ?"; @@ -382,13 +381,9 @@ public class DdsLedger { transfer(from.getSavingsAccountId(), to.getSavingsAccountId(), date, amount, description); } else if (fromSavings) { final SavingsAccount account = load(from.getSavingsAccountId()); - final boolean wasBlocked = unlockDebitsIfBlocked(account); - final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true); - this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags, - this.savingsAccountAssembler.getPivotConfigStatus()); - relockDebits(account, wasBlocked); + final SavingsAccountTransaction withdrawal = withdraw(account, date, amount); postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), - depositLiabilityOf(from), description, null, account.getId()); + depositLiabilityOf(from), description, withdrawal.getId(), account.getId()); } else if (toSavings) { final SavingsAccount account = load(to.getSavingsAccountId()); final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount); @@ -397,7 +392,9 @@ public class DdsLedger { final GLAccount fromDeposits = depositLiabilityOf(from); final GLAccount toDeposits = depositLiabilityOf(to); if (fromDeposits.getId().equals(toDeposits.getId())) { - // Both owed on the same account: nothing to book, but the slip still names who paid whom. + // Both owed on the same account: nothing to book, but the slip still names who paid whom - + // and the two passbook lines claim no journal, since none was written for them. + DdsVoucherScope.forgetLastLedgerTransaction(); voucherSideOnDeposits("DR", from, amount, description); voucherSideOnDeposits("CR", to, amount, description); } else { @@ -410,9 +407,7 @@ public class DdsLedger { /** One pair for a party with no savings account: at the party's own branch, in the bank's currency. */ private void ddsPair(final Party party, final LocalDate date, final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description) { - final Office office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice(); - final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8); - writePair(office, organisationCurrency(), transactionId, date, amount, debit, credit, description, null, null); + writePair(officeOf(party), organisationCurrency(), partyLegId(party), date, amount, debit, credit, description, null, null); } /** @@ -457,8 +452,12 @@ public class DdsLedger { if (productCode == null) { throw new DdsLedgerNotConfiguredException("a DDS product for party " + (party == null ? "?" : party.getPartyCode())); } - final List ids = this.jdbcTemplate.queryForList("select " + column + " from m_dds_account_type " // NOSONAR - + "where account_type_code = ?", Long.class, productCode); + // By the id the account is tied to; by its code only for an account saved before ids were kept. + final List ids = party.getAccountTypeId() != null + ? this.jdbcTemplate.queryForList("select " + column + " from m_dds_account_type where id = ?", Long.class, // NOSONAR + party.getAccountTypeId()) + : this.jdbcTemplate.queryForList("select " + column + " from m_dds_account_type where account_type_code = ?", // NOSONAR + Long.class, productCode); if (ids.isEmpty()) { throw new DdsLedgerNotConfiguredException("DDS product " + productCode + " (party " + party.getPartyCode() + ")"); } @@ -484,28 +483,30 @@ public class DdsLedger { + "sum(pt.amount) as \"volume\" from m_dds_party_transaction pt join m_dds_party p on p.id = pt.party_id " + "left join m_dds_account_type t on t.account_type_code = upper(trim(p.sub_type)) " + "where pt.agent_id = ? and pt.transaction_type = 'PARTY_TRAN' and pt.direction = 'CREDIT' " - + "and pt.commission_batch_run_id is not null %s group by upper(trim(p.sub_type)), t.commission_expense_gl_id " + + "and pt.commission_batch_run_id is not null and pt.commission_batch_run_id < ? %s group by upper(trim(p.sub_type)), t.commission_expense_gl_id " + "order by upper(trim(p.sub_type))"; private static final String UNPAID_COMMISSION_FILTER = "and pt.commission_paid_batch_run_id is null"; private static final String MARK_COMMISSION_PAID_SQL = "update m_dds_party_transaction set commission_paid_batch_run_id = ? " + "where agent_id = ? and transaction_type = 'PARTY_TRAN' and direction = 'CREDIT' and commission_batch_run_id is not null " - + "and commission_paid_batch_run_id is null"; + + "and commission_batch_run_id < ? and commission_paid_batch_run_id is null"; /** * An agent's commission split across the commission accounts of the products they collected for, * in proportion to what they collected for each. The last share takes the rounding, so the shares * always add up to the amount paid. * - * Collections counted before payments were tracked by run have no payment marked against them; if - * nothing unpaid is left, the agent's counted collections as a whole set the proportions instead. + * Only collections counted by commission runs before the paying run are its basis: a run counted + * after this payment was generated earned commission this payment does not include. Collections + * counted before payments were tracked by run have no payment marked against them; if nothing + * unpaid is left, the agent's counted collections as a whole set the proportions instead. */ - public Map commissionSplit(final Long agentId, final BigDecimal amount) { + public Map commissionSplit(final Long agentId, final BigDecimal amount, final Long payingRunId) { List> basis = this.jdbcTemplate.queryForList(String.format(COMMISSION_BASIS_SQL, UNPAID_COMMISSION_FILTER), // NOSONAR - agentId); + agentId, payingRunId); if (basis.isEmpty()) { - basis = this.jdbcTemplate.queryForList(String.format(COMMISSION_BASIS_SQL, ""), agentId); // NOSONAR + basis = this.jdbcTemplate.queryForList(String.format(COMMISSION_BASIS_SQL, ""), agentId, payingRunId); // NOSONAR } if (basis.isEmpty()) { throw new DdsLedgerNotConfiguredException("a collection by this agent to tell which DDS product's Agent Commission Expense " @@ -531,7 +532,7 @@ public class DdsLedger { for (int i = 0; i < ids.size(); i++) { final Long id = ids.get(i); final BigDecimal share = i == ids.size() - 1 || total.signum() == 0 ? amount.subtract(allotted) - : amount.multiply(volumes.get(id)).divide(total, 2, RoundingMode.HALF_EVEN); + : amount.multiply(volumes.get(id)).divide(total, 2, RoundingMode.HALF_UP); allotted = allotted.add(share); if (share.signum() != 0) { shares.put(accounts.get(id), share); @@ -542,7 +543,7 @@ public class DdsLedger { /** Marks the collections a commission payment was shared out on, so the next payment starts after them. */ public void markCommissionPaid(final Long agentId, final Long batchRunId) { - this.jdbcTemplate.update(MARK_COMMISSION_PAID_SQL, batchRunId, agentId); // NOSONAR + this.jdbcTemplate.update(MARK_COMMISSION_PAID_SQL, batchRunId, agentId, batchRunId); // NOSONAR } /** @@ -578,15 +579,9 @@ public class DdsLedger { public void reverseInterest(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description) { final SavingsAccount account = load(savingsAccountId); - // DDS's own movement, so the debit block comes off for it and goes straight back on. - final boolean wasBlocked = unlockDebitsIfBlocked(account); - final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true); - this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags, - this.savingsAccountAssembler.getPivotConfigStatus()); - relockDebits(account, wasBlocked); - + final SavingsAccountTransaction withdrawal = withdraw(account, date, amount); postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), - productAccount(account.productId(), INTEREST_ON_SAVINGS), description, null, account.getId()); + productAccount(account.productId(), INTEREST_ON_SAVINGS), description, withdrawal.getId(), account.getId()); } /** @@ -603,15 +598,15 @@ public class DdsLedger { // The scheme account is blocked for debit so nobody can take the money out at a counter. This // movement is DDS's own, so the block comes off for it and goes straight back on. - final boolean wasBlocked = unlockDebitsIfBlocked(from); - final SavingsTransactionBooleanValues withdrawalFlags = new SavingsTransactionBooleanValues(false, true, false, false, false, true); - this.savingsAccountDomainService.handleWithdrawal(from, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, withdrawalFlags, - this.savingsAccountAssembler.getPivotConfigStatus()); - relockDebits(from, wasBlocked); - + final SavingsAccountTransaction withdrawal = withdraw(from, date, amount); final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount); - postPair(to, deposit, date, amount, controlAccountOf(from), controlAccountOf(to), description); + // One leg on each account, each carrying its own account transaction, so both sides of the + // movement can be traced from the journal - not only the deposit. + writeLeg(from.office(), from.getCurrency().getCode(), "S" + withdrawal.getId(), date, amount, controlAccountOf(from), + JournalEntryType.DEBIT, description, withdrawal.getId(), from.getId()); + writeLeg(from.office(), from.getCurrency().getCode(), "S" + withdrawal.getId(), date, amount, controlAccountOf(to), + JournalEntryType.CREDIT, description, deposit.getId(), to.getId()); } /** @@ -644,15 +639,9 @@ public class DdsLedger { final GLAccount parkIn) { final SavingsAccount account = load(savingsAccountId); - // DDS's own movement, so the debit block comes off for it and goes straight back on. - final boolean wasBlocked = unlockDebitsIfBlocked(account); - final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true); - this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags, - this.savingsAccountAssembler.getPivotConfigStatus()); - relockDebits(account, wasBlocked); - - postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), parkIn, - description, null, account.getId()); + final SavingsAccountTransaction withdrawal = withdraw(account, date, amount); + postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), parkIn, description, + withdrawal.getId(), account.getId()); } /** @@ -761,20 +750,54 @@ public class DdsLedger { private void postPair(final Office office, final String currencyCode, final LocalDate date, final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description, final Long savingsTransactionId, final Long savingsAccountId) { - final String transactionId = savingsTransactionId == null ? "DDS" + date + amount : "S" + savingsTransactionId; + final String transactionId = savingsTransactionId == null ? "DDS-" + UUID.randomUUID() : "S" + savingsTransactionId; writePair(office, currencyCode, transactionId, date, amount, debit, credit, description, savingsTransactionId, savingsAccountId); } private void writePair(final Office office, final String currencyCode, final String transactionId, final LocalDate date, final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description, final Long savingsTransactionId, final Long savingsAccountId) { - // Noted for the voucher of the operation this leg belongs to - see DdsVoucherService. + writeLeg(office, currencyCode, transactionId, date, amount, debit, JournalEntryType.DEBIT, description, savingsTransactionId, + savingsAccountId); + writeLeg(office, currencyCode, transactionId, date, amount, credit, JournalEntryType.CREDIT, description, savingsTransactionId, + savingsAccountId); + } + + private void writeLeg(final Office office, final String currencyCode, final String transactionId, final LocalDate date, + final BigDecimal amount, final GLAccount account, final JournalEntryType type, final String description, + final Long savingsTransactionId, final Long savingsAccountId) { + // Noted for the voucher of the operation this leg belongs to - see DdsVoucherService - and as the + // journal the next passbook line of the operation is linked to (m_dds_party_transaction.journal_transaction_id). DdsVoucherScope.recordLedgerTransaction(transactionId); - for (final JournalEntryType type : List.of(JournalEntryType.DEBIT, JournalEntryType.CREDIT)) { - this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null, - JournalEntryType.DEBIT.equals(type) ? debit : credit, currencyCode, transactionId, false, date, type, amount, - description, PortfolioProductType.SAVING.getValue(), savingsAccountId, null, null, savingsTransactionId, null, null)); - } + this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null, account, currencyCode, transactionId, false, date, type, + amount, description, PortfolioProductType.SAVING.getValue(), savingsAccountId, null, null, savingsTransactionId, null, + null)); + } + + /** + * A journal id for a leg on DDS's own books - the party's code to find it by and a full UUID to keep it + * unique: DDS-PT0003-1b4e... Never built from the date and amount, which two parties can share. + */ + private static String partyLegId(final Party party) { + return "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } + + /** The branch a party's legs are booked at: the party's own, or its agent's for a party opened without one. */ + public Office officeOf(final Party party) { + return party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice(); + } + + /** + * Takes money off a scheme account that DDS keeps blocked for debits, as DDS's own movement - the block + * comes off for it and goes straight back on - and returns the withdrawal so its journal can name it. + */ + private SavingsAccountTransaction withdraw(final SavingsAccount account, final LocalDate date, final BigDecimal amount) { + final boolean wasBlocked = unlockDebitsIfBlocked(account); + final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true); + final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(account, + DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags, this.savingsAccountAssembler.getPivotConfigStatus()); + relockDebits(account, wasBlocked); + return withdrawal; } private GLAccount controlAccountOf(final SavingsAccount account) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsVoucherService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsVoucherService.java index 0f70081a7..4fe737a16 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsVoucherService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounting/DdsVoucherService.java @@ -336,6 +336,9 @@ public class DdsVoucherService { LAST_ISSUED.set(voucherNo); stamp("update m_dds_party_transaction set voucher_no = ? where id in (%s)", voucherNo, scope.getPartyTransactionIds()); + // The journal legs carry the voucher too, in ref_num, so the slip, the passbook lines and the + // journal can each be found from the others - as a savings voucher's legs can. + stampLegs(voucherNo, scope.getLedgerTransactionIds()); stamp("update m_savings_account_transaction set voucher_no = ? where voucher_no is null and id in (%s)", voucherNo, savingsTransactions); } @@ -427,6 +430,17 @@ public class DdsVoucherService { "select deposit_liability_gl_id from m_dds_account_type where deposit_liability_gl_id is not null", Long.class)); } + private void stampLegs(final String voucherNo, final Collection transactionIds) { + if (transactionIds.isEmpty()) { + return; + } + final List args = new ArrayList<>(); + args.add(voucherNo); + args.addAll(transactionIds); + this.jdbcTemplate.update("update acc_gl_journal_entry set ref_num = ? where ref_num is null and transaction_id in (" // NOSONAR + + placeholders(transactionIds) + ")", args.toArray()); + } + private void stamp(final String sql, final String voucherNo, final Collection ids) { if (ids.isEmpty()) { return; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformServiceJpaRepositoryImpl.java index e6306bd71..cb3259b6c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/accounttype/service/DdsAccountTypeWritePlatformServiceJpaRepositoryImpl.java @@ -115,6 +115,13 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA changesOnly.put("accountsRepriced", openAccounts.size()); } + // A renamed product: the accounts on it - tied to it by id - show the new code. + if (changesOnly.containsKey("accountTypeCode")) { + final int moved = this.jdbcTemplate.update("update m_dds_party set sub_type = ? where account_type_id = ?", // NOSONAR + accountType.getAccountTypeCode(), accountTypeId); + changesOnly.put("accountsRenamed", moved); + } + return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // .withEntityId(accountTypeId) // diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java index 66b6f6075..ab62fa2dc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/approval/service/DdsApprovalReadService.java @@ -588,7 +588,7 @@ public class DdsApprovalReadService { for (final Map runRow : this.jdbcTemplate.queryForList( // NOSONAR "select agent_id, amount from m_dds_batch_run_row where batch_run_id = ? and amount > 0 order by id", resourceId)) { try { - this.ddsLedger.commissionSplit(((Number) runRow.get("agent_id")).longValue(), (BigDecimal) runRow.get("amount")) + this.ddsLedger.commissionSplit(((Number) runRow.get("agent_id")).longValue(), (BigDecimal) runRow.get("amount"), resourceId) .forEach((account, share) -> { byAccount.merge(account.getGlCode(), share, BigDecimal::add); names.put(account.getGlCode(), account.getName()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/api/BatchRunsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/api/BatchRunsApiResource.java index bbdccaec6..b37e58d27 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/api/BatchRunsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/api/BatchRunsApiResource.java @@ -22,6 +22,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; @@ -38,6 +39,8 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.dds.batch.data.BatchRunData; import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformService; +import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.springframework.stereotype.Component; @Path("/v1/dds/batchruns") @@ -52,6 +55,20 @@ public class BatchRunsApiResource { private final BatchRunReadPlatformService readPlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final DdsVoucherService ddsVoucherService; + private final BatchRunWritePlatformService writePlatformService; + + @DELETE + @Path("{batchRunId}") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Discard a previewed DDS Batch Run", description = "Deletes a run that was generated but never posted - " + + "the preview the screen's Discard button throws away. Refused for a run that is posted, posting, failed, or whose " + + "posting is waiting for approval.") + public CommandProcessingResult discard(@PathParam("batchRunId") @Parameter(description = "batchRunId") final Long batchRunId) { + // Whoever may generate a preview may throw one away; it moved nothing. + this.context.authenticatedUser().validateHasPermissionTo("GENERATE_" + RESOURCE_NAME_FOR_PERMISSIONS); + this.writePlatformService.discardBatchRun(batchRunId); + return new CommandProcessingResultBuilder().withEntityId(batchRunId).build(); + } @GET @Consumes({ MediaType.APPLICATION_JSON }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformService.java index 7163b28e6..7bf7b5a70 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformService.java @@ -30,6 +30,12 @@ public interface BatchRunWritePlatformService { CommandProcessingResult postBatchRun(Long batchRunId, JsonCommand command); + /** + * Throws away a run that was only previewed - generated and never posted, nor waiting for a + * checker to approve its posting. Nothing was applied by generating it, so nothing is undone. + */ + void discardBatchRun(Long batchRunId); + /** * Posts interest for every scheme whose Account Type says to do it automatically and whose posting * day falls on the given date. Driven by the Post DDS Interest scheduled job. diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java index 95197ac1e..39471cb22 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/batch/service/BatchRunWritePlatformServiceJpaRepositoryImpl.java @@ -29,6 +29,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.accounting.glaccount.domain.GLAccount; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; @@ -84,6 +85,7 @@ import org.springframework.transaction.support.TransactionSynchronizationManager * re-running it for the same period finds no days left; a posting takes its amount off the parked * figure, so re-posting finds nothing left to move. */ +@Slf4j @RequiredArgsConstructor public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWritePlatformService { @@ -194,6 +196,9 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType); } + // A new preview of the same job replaces the one before it: generating twice used to leave two + // identical "not posted" runs on the list, and nobody could tell which one to post. + discardSupersededPreviews(jobType, subType); this.batchRunRepository.saveAndFlush(batchRun); return new CommandProcessingResultBuilder() // @@ -202,6 +207,35 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr .build(); } + /** + * A run that is only a preview: calculated, never posted, not posting or failed, and with no + * posting of it waiting for a checker (a held posting names the run, and approving it later must + * find it). Only such a run is ever deleted - generating applies nothing, so nothing is lost. + */ + private static final String PREVIEW_ONLY = "r.status = 'CALCULATED' and not exists (select 1 from m_portfolio_command_source c " + + "where c.entity_name = 'DDSBATCH' and c.resource_id = r.id and c.status = 2)"; + + private void discardSupersededPreviews(final String jobType, final String subType) { + final String sameJob = "r.job_type = ? and coalesce(r.sub_type, '') = coalesce(?, '') and " + PREVIEW_ONLY; + this.jdbcTemplate.update("delete from m_dds_batch_run_row where batch_run_id in (select r.id from m_dds_batch_run r where " // NOSONAR + + sameJob + ")", jobType, subType); + this.jdbcTemplate.update("delete from m_dds_batch_run r where " + sameJob, jobType, subType); // NOSONAR + } + + @Transactional + @Override + public void discardBatchRun(final Long batchRunId) { + final List found = this.jdbcTemplate.queryForList("select r.id from m_dds_batch_run r where r.id = ? and " + PREVIEW_ONLY, // NOSONAR + Long.class, batchRunId); + if (found.isEmpty()) { + throw new PlatformDataIntegrityException("error.msg.dds.batch.run.not.discardable", + "Batch run #" + batchRunId + " cannot be discarded: only a run that was generated and not posted (or sent for" + + " posting approval) can be."); + } + this.jdbcTemplate.update("delete from m_dds_batch_run_row where batch_run_id = ?", batchRunId); // NOSONAR + this.jdbcTemplate.update("delete from m_dds_batch_run where id = ?", batchRunId); // NOSONAR + } + /** * Parses the "Branch" checklist selection - null/empty means no filter ("All"). Matched against * Agent.homeBranch by name, since that field is plain text (not a real Office FK) mirroring the @@ -601,26 +635,47 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr final String jobType = batchRun.getJobType(); final LocalDate jobDate = batchRun.getJobDate(); + // Only rows that actually moved money are counted, and their sum kept beside the run's total: a + // posting row is passed over when another run has already paid what it lists, and the run must + // not read as though it paid it again. int done = 0; + BigDecimal moved = BigDecimal.ZERO; for (final BatchRunRow row : batchRun.getRows()) { - switch (jobType) { - case INTEREST_CALCULATION -> accrue(row, jobDate, false); - case OD_INTEREST_CALCULATION -> accrue(row, jobDate, true); + final boolean applied = switch (jobType) { + case INTEREST_CALCULATION -> { + accrue(row, jobDate, false); + yield true; + } + case OD_INTEREST_CALCULATION -> { + accrue(row, jobDate, true); + yield true; + } case INTEREST_POSTING -> postInterest(row, batchRun, false); case OD_INTEREST_POSTING -> postInterest(row, batchRun, true); - case COMMISSION_CALCULATION -> accrueCommission(row, batchRun); + case COMMISSION_CALCULATION -> { + accrueCommission(row, batchRun); + yield true; + } case COMMISSION_POSTING -> payCommission(row, batchRun); - case MATURITY_AUTO_CLOSE -> closeAtMaturity(row); + case MATURITY_AUTO_CLOSE -> { + closeAtMaturity(row); + yield true; + } default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType); + }; + if (applied) { + done++; + moved = moved.add(row.getAmount() == null ? BigDecimal.ZERO : row.getAmount()); + } else { + log.warn("DDS batch run #{}: row {} passed over - what it lists was already paid", batchRun.getId(), row.getId()); } - done++; } // Only at the end. A crediting run is one transaction from start to finish, so a count written // part-way through would be invisible outside it until the run commits - and writing it from a // transaction of its own would have two of them contending for the same row. The status says // POSTING while it works, which is what a clerk actually needs to know. - batchRun.recordProgress(done); + batchRun.recordPosted(done, moved); } /** @@ -667,10 +722,10 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr * same accrued interest would each list it in full, and paying both would credit interest that * was never earned. */ - private void postInterest(final BatchRunRow row, final BatchRun batchRun, final boolean odInterest) { + private boolean postInterest(final BatchRunRow row, final BatchRun batchRun, final boolean odInterest) { final Party party = partyOf(row.getPartyId()); if (party.accruedInterest(odInterest).compareTo(row.getAmount()) < 0) { - return; + return false; } final BigDecimal newBalance = party.postInterest(row.getAmount(), odInterest); @@ -683,6 +738,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr final String transactionType = odInterest ? "OD_INTEREST_POST" : "INTEREST_POST"; this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), batchRun.getJobDate(), transactionType, DIRECTION_CREDIT, null, null, null, row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(), newBalance)); + return true; } /** @@ -721,12 +777,12 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr * customers and not yet handed to a branch, which commission has nothing to do with. The entry is * recorded against the balance as it stands, so the ledger reads consistently. */ - private void payCommission(final BatchRunRow row, final BatchRun batchRun) { + private boolean payCommission(final BatchRunRow row, final BatchRun batchRun) { final Agent agent = agentOf(row.getAgentId()); // Only what is actually owed can be paid: two posting runs generated over the same owed // figure would each list it in full, and paying both would pay commission twice. if (agent.getCommission().compareTo(row.getAmount()) < 0) { - return; + return false; } agent.payOutCommission(row.getAmount()); @@ -735,6 +791,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr this.partyTransactionRepository.save(new PartyTransaction(null, agent, batchRun.getJobDate(), "COMMISSION_POST", DIRECTION_CREDIT, null, null, agent.getAgentSavingAccountNo(), row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(), agent.getAgentCurrentBalance())); + return true; } /** @@ -794,7 +851,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr // Each product the agent collected for books its share on its own commission account; the // collections the share was worked out on are then marked as paid for. - final Map shares = this.ddsLedger.commissionSplit(agent.getId(), amount); + final Map shares = this.ddsLedger.commissionSplit(agent.getId(), amount, batchRun.getId()); this.ddsLedger.payCommission(((Number) account.get("id")).longValue(), batchRun.getJobDate(), amount, "DDS commission, batch run #" + batchRun.getId(), shares); this.ddsLedger.markCommissionPaid(agent.getId(), batchRun.getId()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/serialization/CollectionEntryCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/serialization/CollectionEntryCommandFromApiJsonDeserializer.java index 9aa4b26a5..9c0cc8264 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/serialization/CollectionEntryCommandFromApiJsonDeserializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/serialization/CollectionEntryCommandFromApiJsonDeserializer.java @@ -115,6 +115,7 @@ public final class CollectionEntryCommandFromApiJsonDeserializer { .inMinMaxRange(1, DdsSchedule.CATCH_UP_DAYS); } else { v.reset().parameter(ENTRIES).parameterAtIndexArray("amount", i).value(decimalOf(row)).notNull().positiveAmount(); + toThePaisa(v, decimalOf(row)); } final String mode = stringOf(row, "transactionMode"); @@ -201,4 +202,14 @@ public final class CollectionEntryCommandFromApiJsonDeserializer { throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors); } } + /** + * Money is kept to the paisa, as a savings account keeps it: 100.555 is refused rather than stored + * on the passbook and the journal as typed. + */ + private static void toThePaisa(final DataValidatorBuilder v, final BigDecimal amount) { + if (amount != null && amount.stripTrailingZeros().scale() > 2) { + v.failWithCodeAndMessage("more.than.two.decimals", "An amount can have at most two decimal places (paise).", amount); + } + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryWritePlatformServiceJpaRepositoryImpl.java index 54c51b9a9..9bbbc08d2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/collection/service/CollectionEntryWritePlatformServiceJpaRepositoryImpl.java @@ -49,6 +49,7 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte import org.apache.fineract.organisation.agent.domain.Agent; import org.apache.fineract.organisation.agent.domain.AgentRepository; import org.apache.fineract.organisation.agent.exception.AgentNotFoundException; +import org.apache.fineract.organisation.office.domain.Office; import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntry; import org.apache.fineract.portfolio.dds.collection.domain.CollectionEntryRepository; import org.apache.fineract.portfolio.dds.collection.exception.CollectionEntryNotFoundException; @@ -336,39 +337,49 @@ public class CollectionEntryWritePlatformServiceJpaRepositoryImpl implements Col } final String description = "DDS collection by " + agent.getAgentCode() + ", voucher " + String.join(", ", voucherNos); - // What the customers on DDS's own books are now owed, by the customer-deposits account of - // their product - two products may owe on two accounts. - final Map pooled = new LinkedHashMap<>(); - final Map depositAccounts = new LinkedHashMap<>(); + // What the customers on DDS's own books are now owed, by the customer-deposits account of their + // product and the branch they bank at - two products owe on two accounts, and a branch's DDS + // Customer Deposits must add up to its own customers' balances. + final String journalId = "DDSR-A" + applicationId; + final Map pooled = new LinkedHashMap<>(); + final Map depositAccounts = new LinkedHashMap<>(); + final Map offices = new LinkedHashMap<>(); for (final CollectionEntry entry : pending) { final Party party = collectableParty(entry.getParty().getPartyCode(), agent); - if (party.getSavingsAccountId() != null) { + final boolean pooledEntry = party.getSavingsAccountId() == null; + if (!pooledEntry) { this.ddsLedger.applyAgentReceiptToSavings(party, settledOn, entry.getAmount(), receiptAccount, description); } else { final GLAccount deposits = this.ddsLedger.depositLiabilityOf(party); - depositAccounts.putIfAbsent(deposits.getId(), deposits); - pooled.merge(deposits.getId(), entry.getAmount(), BigDecimal::add); + final Office office = this.ddsLedger.officeOf(party); + final String key = office.getId() + ":" + deposits.getId(); + depositAccounts.putIfAbsent(key, deposits); + offices.putIfAbsent(key, office); + pooled.merge(key, entry.getAmount(), BigDecimal::add); } // The customer's own statement records the day the money was collected, as it always has. // The agent's cash in hand is not raised: this cash was counted in at the counter, not // carried. final BigDecimal balanceAfter = party.credit(entry.getAmount()); - final PartyTransaction transaction = this.partyTransactionRepository.saveAndFlush(new PartyTransaction(party, agent, - entry.getEntryDate(), "PARTY_TRAN", "CREDIT", entry.getTransactionMode(), "GL", receiptAccount.getGlCode(), - entry.getAmount(), entry.getChequeNo(), entry.getTokenNo(), description, balanceAfter)); + final PartyTransaction line = new PartyTransaction(party, agent, entry.getEntryDate(), "PARTY_TRAN", "CREDIT", + entry.getTransactionMode(), "GL", receiptAccount.getGlCode(), entry.getAmount(), entry.getChequeNo(), + entry.getTokenNo(), description, balanceAfter); + // A pooled line's journal is written after the loop, so it is named here rather than taken + // from whatever the operation wrote last. + final PartyTransaction transaction = this.partyTransactionRepository + .saveAndFlush(pooledEntry ? line.linkJournal(journalId) : line); entry.markSettledByReceipt(settledOn, transaction.getId(), applicationId); this.collectionEntryRepository.save(entry); } - // Everyone on DDS's own accounts, in one pair per customer-deposits account, all under one journal id. - final String journalId = "DDSR-A" + applicationId; + // Everyone on DDS's own accounts, in one pair per branch and customer-deposits account, all under one journal id. boolean journalled = false; - for (final Map.Entry owed : pooled.entrySet()) { + for (final Map.Entry owed : pooled.entrySet()) { if (owed.getValue().signum() > 0) { - this.ddsLedger.applyAgentReceipts(agent.getOffice(), settledOn, owed.getValue(), receiptAccount, + this.ddsLedger.applyAgentReceipts(offices.get(owed.getKey()), settledOn, owed.getValue(), receiptAccount, depositAccounts.get(owed.getKey()), journalId, description); journalled = true; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformServiceImpl.java index 56c4bf421..01c06c5a0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyReadPlatformServiceImpl.java @@ -104,7 +104,10 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService { + " p.freeze_code as freezeCode, p.adv_code as advCode, p.credit_to_branch as creditToBranch, " + " p.credit_to_account_type as creditToAccountType, p.credit_to_account as creditToAccount, " + " p.nominee_applicable as nomineeApplicable, p.nominee_split_mode as nomineeSplitMode, " - + " p.status as status, p.close_date as closeDate " + + " p.status as status, p.close_date as closeDate, p.account_type_id as accountTypeId, " + + " p.currency_code as currencyCode, p.activatedon_date as activatedOnDate, " + + " (select u.username from m_appuser u where u.id = p.activatedon_userid) as activatedByUsername, " + + " (select u.username from m_appuser u where u.id = p.closedon_userid) as closedByUsername " // Left join: sub_type is a plain code column rather than a foreign key, so a party // whose account type was renamed away or predates the master still lists, just // without a description. @@ -214,6 +217,11 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService { .nomineeSplitMode(rs.getString("nomineeSplitMode")) // .status(rs.getString("status")) // .closeDate(JdbcSupport.getLocalDate(rs, "closeDate")) // + .accountTypeId(JdbcSupport.getLong(rs, "accountTypeId")) // + .currencyCode(rs.getString("currencyCode")) // + .activatedOnDate(JdbcSupport.getLocalDate(rs, "activatedOnDate")) // + .activatedByUsername(rs.getString("activatedByUsername")) // + .closedByUsername(rs.getString("closedByUsername")) // .nominees(new ArrayList<>()) // .build(); } @@ -345,10 +353,10 @@ public class PartyReadPlatformServiceImpl implements PartyReadPlatformService { @Override public String lastAccountNo() { - // The newest account by id - an account's number is given from its id, so the highest id is the - // one opened last. Closed accounts count: this says what was opened, not what is still open. + // The highest number on the DDS account serial - the account opened last. Closed accounts count: + // this says what was opened, not what is still open. final List found = this.jdbcTemplate.queryForList( // NOSONAR - "select account_no from m_dds_party where account_no is not null order by id desc limit 1", String.class); + "select account_no from m_dds_party where account_no is not null order by account_no desc limit 1", String.class); return found.isEmpty() ? null : found.get(0); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java index 26950bc72..80e23f424 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/party/service/PartyWritePlatformServiceJpaRepositoryImpl.java @@ -29,6 +29,7 @@ import java.time.LocalDate; import java.time.Period; import java.time.format.DateTimeFormatter; import java.util.HashMap; +import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Map; @@ -82,6 +83,7 @@ import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepo import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository; import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService; +import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.orm.jpa.JpaSystemException; @@ -133,15 +135,20 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla final String partyCode = generateNextPartyCode(); final Party party = Party.fromJson(client, agent, officeNamed(command.longValueOfParameterNamed(OFFICE_ID)), partyCode, command); - party.takeSchemeRate(schemeOf(party).getInterestRate()); + final DdsAccountType scheme = schemeOf(party); + party.takeSchemeRate(scheme.getInterestRate()); + party.linkProduct(scheme.getId()); + // Operative from the day it is opened - by whoever executes this, the checker where the + // opening went through maker-checker - in the bank's currency. + party.recordActivation(organisationCurrency(), DateUtils.getBusinessLocalDate(), currentUserId()); linkSavingsAccountIfProvided(party, clientId, command); addNomineesFromCommand(party, command); - // The party is the DDS account: nothing is opened in the savings tables for it. Its - // number comes from its id, so it is given once the first save has made one. - this.partyRepository.saveAndFlush(party); - party.assignAccountNo(); + // The party is the DDS account: nothing is opened in the savings tables for it. Its number + // is the next on the DDS account serial, taken in this transaction - so an opening that + // fails or is rolled back hands its number back, and the numbers run without gaps. + party.assignAccountNo(nextAccountSerial()); this.partyRepository.saveAndFlush(party); return new CommandProcessingResultBuilder() // @@ -166,6 +173,10 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla this.fromApiJsonDeserializer.validateForUpdate(command.json()); final Party partyForUpdate = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId)); + refuseEditOfClosedAccount(partyForUpdate); + // Asked before the update changes anything: is the branch this request names a different one? + final boolean officeMoves = command.parameterExists(OFFICE_ID) && (partyForUpdate.getOffice() == null + || !partyForUpdate.getOffice().getId().equals(command.longValueOfParameterNamed(OFFICE_ID))); // Checked against whoever owns the party once this update lands, not who owns it now - // moving the party to another customer and naming the old one's account in the same save @@ -175,13 +186,16 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla validateCreditToAccount(owningClientId, command); final Map changesOnly = partyForUpdate.update(command); + refuseChangeOfLockedTerms(partyForUpdate, changesOnly, officeMoves); // Only when it actually changed: a party already sitting on a since-retired account type // must stay editable for everything else. if (changesOnly.containsKey("subType")) { validateSubType((String) changesOnly.get("subType")); - // Moved to another scheme: it takes that scheme's rate. - partyForUpdate.takeSchemeRate(schemeOf(partyForUpdate).getInterestRate()); + // Moved to another scheme: it takes that scheme's rate, and is tied to it by id. + final DdsAccountType newScheme = schemeOf(partyForUpdate); + partyForUpdate.takeSchemeRate(newScheme.getInterestRate()); + partyForUpdate.linkProduct(newScheme.getId()); changesOnly.put("interestRate", partyForUpdate.getInterestRate()); } @@ -244,7 +258,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla } final LocalDate closeDate = command.localDateValueOfParameterNamed("closeDate"); - party.close(closeDate); + party.close(closeDate, currentUserId()); this.partyRepository.saveAndFlush(party); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).build(); @@ -346,7 +360,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla // open only leaves a second step to forget - an account that reads as active in every report and // that an agent could still collect on. The same as Maturity Auto Close does, on the day the // money moved. - party.close(DateUtils.getBusinessLocalDate()); + party.close(DateUtils.getBusinessLocalDate(), currentUserId()); this.partyRepository.saveAndFlush(party); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).build(); @@ -444,7 +458,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla // The balance is nil now, which is the one thing closing insists on. Closed on the business // date rather than the maturity date: this is the day the money actually moved, and a close // date earlier than its own payout would read as though the account had been shut first. - party.close(DateUtils.getBusinessLocalDate()); + party.close(DateUtils.getBusinessLocalDate(), currentUserId()); this.partyRepository.saveAndFlush(party); return new CommandProcessingResultBuilder().withEntityId(partyId).build(); @@ -740,6 +754,68 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla * counted at another. Refuses an id that names no branch rather than leaving the account * unplaced. */ + /** + * Who is doing this, for the account's own record of it. A background run has no logged-in user; + * it is recorded as the system administrator, as the audit columns record it (AuditorAwareImpl). + */ + private Long currentUserId() { + final AppUser user = this.context.getAuthenticatedUserIfPresent(); + return user != null ? user.getId() : SYSTEM_ADMIN_USER_ID; + } + + private static final Long SYSTEM_ADMIN_USER_ID = 1L; + + /** The bank's currency - a DDS account is opened in it. */ + private String organisationCurrency() { + final List codes = this.jdbcTemplate.queryForList("select code from m_organisation_currency order by id limit 1", // NOSONAR + String.class); + return codes.isEmpty() ? null : codes.get(0); + } + + /** + * What an active DDS account's scheme is built on, and so cannot change while it runs - as a savings + * account's product, currency and opening cannot: the customer and product, the branch whose books + * carry it, the opening and its balance, and the deposit terms (daily amount, deposit, days, months) + * that its maturity amount and interest are worked out from, with the dates and amounts that follow + * from them. Changing one mid-scheme would leave the balance, the interest already posted and the + * maturity figure answering to terms the account no longer has. The collecting agent is locked too: + * the agent's pending collections, commission and cash in hand are counted against the account. + * Contact details, nominees, lien and freeze, SMS/e-mail and where the maturity is paid stay editable. + */ + private static final List LOCKED_WHEN_ACTIVE = List.of("clientId", "subType", "agentId", "openingDate", "openingBalance", + "dailyDepositAmount", "depositAmount", "days", "month", "interestDate", "payDate", "paymentAmount", "reopenDate", + "lastAccount"); + + private void refuseEditOfClosedAccount(final Party party) { + if (party.isClosed()) { + final String message = "DDS account " + party.getAccountNo() + " is closed and cannot be edited."; + throw new PlatformApiDataValidationException("error.msg.dds.party.closed.not.editable", message, + List.of(ApiParameterError.parameterError("error.msg.dds.party.closed.not.editable", message, "status"))); + } + } + + private void refuseChangeOfLockedTerms(final Party party, final Map changes, final boolean officeMoves) { + if (!Party.STATUS_ACTIVE.equals(party.getStatus())) { + return; + } + final List errors = new ArrayList<>(); + for (final String field : LOCKED_WHEN_ACTIVE) { + if (changes.containsKey(field)) { + errors.add(ApiParameterError.parameterError("error.msg.dds.party.term.locked." + field, + "'" + field + "' cannot be changed on an active DDS account.", field)); + } + } + if (officeMoves) { + errors.add(ApiParameterError.parameterError("error.msg.dds.party.term.locked." + OFFICE_ID, + "The branch cannot be changed on an active DDS account.", OFFICE_ID)); + } + if (!errors.isEmpty()) { + // Thrown inside the update's transaction, so nothing the request changed is kept. + throw new PlatformApiDataValidationException("error.msg.dds.party.terms.locked", + "These cannot be changed on an active DDS account: " + errors.size() + " field(s).", errors); + } + } + private Office officeNamed(final Long officeId) { return this.officeRepositoryWrapper.findOneWithNotFoundDetection(officeId); } @@ -874,7 +950,7 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla payOutRemainder(party, command, closeDate, lockinOverrideReason); - party.close(closeDate); + party.close(closeDate, currentUserId()); this.partyRepository.saveAndFlush(party); final Map changes = new HashMap<>(); @@ -1060,6 +1136,20 @@ public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePla } } + /** + * The next DDS account number. The update locks the serial's one row until this transaction ends, + * so two openings at once queue on it rather than taking the same number, and a rollback undoes the + * increment - unlike a sequence, which never gives a number back. + */ + private long nextAccountSerial() { + final Long next = this.jdbcTemplate.queryForObject( // NOSONAR + "update m_dds_account_number_serial set last_value = last_value + 1 where id = 1 returning last_value", Long.class); + if (next == null) { + throw new IllegalStateException("m_dds_account_number_serial has no row - migration 0624 has not run"); + } + return next; + } + private String generateNextPartyCode() { final Long maxSuffix = this.partyRepository.retrieveMaxPartyCodeSuffix(); final long nextSuffix = (maxSuffix == null ? 0L : maxSuffix) + 1L; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/serialization/PartyTransactionCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/serialization/PartyTransactionCommandFromApiJsonDeserializer.java index 13796cd26..cd847fc3c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/serialization/PartyTransactionCommandFromApiJsonDeserializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/serialization/PartyTransactionCommandFromApiJsonDeserializer.java @@ -22,6 +22,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -161,6 +162,7 @@ public final class PartyTransactionCommandFromApiJsonDeserializer { } else { v.reset().parameter(COLLECTIONS).parameterAtIndexArray("amount", i).value(extractBigDecimal(row, "amount")) .notNull().positiveAmount(); + toThePaisa(v, extractBigDecimal(row, "amount")); } } } @@ -171,9 +173,11 @@ public final class PartyTransactionCommandFromApiJsonDeserializer { .notBlank(); v.reset().parameter("withdrawAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("withdrawAmount", element)) .notNull().positiveAmount(); + toThePaisa(v, this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("withdrawAmount", element)); } else if ("AGENT_TRAN".equals(transactionType)) { v.reset().parameter("amount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("amount", element)).notNull() .positiveAmount(); + toThePaisa(v, this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("amount", element)); // An agent handing its round's takings to the branch is a counter transaction like any // other, so it says how the money arrived on the same terms a collection does. @@ -207,4 +211,14 @@ public final class PartyTransactionCommandFromApiJsonDeserializer { dataValidationErrors); } } + /** + * Money is kept to the paisa, as a savings account keeps it: 100.555 is refused rather than stored + * on the passbook and the journal as typed. + */ + private static void toThePaisa(final DataValidatorBuilder v, final BigDecimal amount) { + if (amount != null && amount.stripTrailingZeros().scale() > 2) { + v.failWithCodeAndMessage("more.than.two.decimals", "An amount can have at most two decimal places (paise).", amount); + } + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionReadPlatformServiceImpl.java index 74a0c0c41..65ff5bb08 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/dds/transaction/service/PartyTransactionReadPlatformServiceImpl.java @@ -45,9 +45,13 @@ public class PartyTransactionReadPlatformServiceImpl implements PartyTransaction + " t.agent_id as agentId, a.agent_code as agentCode, t.transaction_date as transactionDate, " + " t.transaction_type as transactionType, t.direction as direction, t.transaction_mode as transactionMode, " + " t.account_type as accountType, t.account_no as accountNo, t.amount as amount, t.cheque_no as chequeNo, " - + " t.token_no as tokenNo, t.description as description, t.balance_after_transaction as balanceAfterTransaction, t.voucher_no as voucherNo " + + " t.token_no as tokenNo, t.description as description, t.balance_after_transaction as balanceAfterTransaction, t.voucher_no as voucherNo, " + + " t.office_id as officeId, o.name as officeName, t.submitted_on_date as submittedOnDate, " + + " t.journal_transaction_id as journalTransactionId, t.ref_no as refNo, " + + " u.username as createdByUsername, t.created_date as createdDate, a.agent_name as agentName " + " from m_dds_party_transaction t left join m_dds_party p on p.id = t.party_id " - + " join m_dds_agent a on a.id = t.agent_id"; + + " join m_dds_agent a on a.id = t.agent_id left join m_office o on o.id = t.office_id " + + " left join m_appuser u on u.id = t.createdby_id"; } @Override @@ -71,6 +75,14 @@ public class PartyTransactionReadPlatformServiceImpl implements PartyTransaction .description(rs.getString("description")) // .balanceAfterTransaction(rs.getBigDecimal("balanceAfterTransaction")) // .voucherNo(rs.getString("voucherNo")) // + .officeId(JdbcSupport.getLong(rs, "officeId")) // + .officeName(rs.getString("officeName")) // + .submittedOnDate(JdbcSupport.getLocalDate(rs, "submittedOnDate")) // + .journalTransactionId(rs.getString("journalTransactionId")) // + .refNo(rs.getString("refNo")) // + .createdByUsername(rs.getString("createdByUsername")) // + .createdDate(rs.getTimestamp("createdDate") == null ? null : rs.getTimestamp("createdDate").toLocalDateTime()) // + .agentName(rs.getString("agentName")) // .build(); } } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 683d2f3d7..39cfad061 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -193,4 +193,8 @@ + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0622_dds_transaction_integrity.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0622_dds_transaction_integrity.xml new file mode 100644 index 000000000..e0162da6c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0622_dds_transaction_integrity.xml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + update m_dds_party_transaction t + set office_id = coalesce((select p.office_id from m_dds_party p where p.id = t.party_id), + (select a.office_id from m_dds_agent a where a.id = t.agent_id)) + where t.office_id is null; + update m_dds_party_transaction + set submitted_on_date = coalesce(cast(created_date as date), transaction_date) + where submitted_on_date is null; + update m_dds_party_transaction t + set journal_transaction_id = (select ra.journal_transaction_id from m_dds_collection_entry e + join m_dds_receipt_application ra on ra.id = e.receipt_application_id + where e.party_transaction_id = t.id + limit 1) + where t.journal_transaction_id is null; + update acc_gl_journal_entry j + set ref_num = (select max(t.voucher_no) from m_dds_party_transaction t + where t.journal_transaction_id = j.transaction_id) + where j.ref_num is null + and exists (select 1 from m_dds_party_transaction t + where t.journal_transaction_id = j.transaction_id and t.voucher_no is not null); + + + + + + select count(*) from m_dds_party_transaction where office_id is null or submitted_on_date is null + + + + + + + + + + + + + alter table m_dds_party_transaction add constraint chk_dds_party_txn_amount check (amount > 0); + alter table m_dds_party_transaction add constraint chk_dds_party_txn_direction check (direction in ('CREDIT', 'DEBIT')); + alter table m_dds_party_transaction add constraint chk_dds_party_txn_type check (transaction_type in + ('PARTY_TRAN', 'AGENT_TRAN', 'WITHDRAW_DEPOSITE', 'INTEREST_POST', 'OD_INTEREST_POST', 'COMMISSION_POST', + 'MATURITY_PAYOUT', 'PREMATURE_INT_RECOVERY', 'PREMATURE_AGENT_TOPUP', 'PREMATURE_PAYOUT')); + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0623_dds_account_audit_and_links.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0623_dds_account_audit_and_links.xml new file mode 100644 index 000000000..bff37db63 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0623_dds_account_audit_and_links.xml @@ -0,0 +1,175 @@ + + + + + + + + update m_dds_party set createdby_id = 1 where createdby_id is null; + update m_dds_party set created_date = coalesce(lastmodified_date, current_timestamp) where created_date is null; + update m_dds_agent set createdby_id = 1 where createdby_id is null; + update m_dds_agent set created_date = coalesce(lastmodified_date, current_timestamp) where created_date is null; + update m_dds_account_type set createdby_id = 1 where createdby_id is null; + update m_dds_account_type set created_date = coalesce(lastmodified_date, current_timestamp) where created_date is null; + update m_dds_party_transaction set createdby_id = 1 where createdby_id is null; + update m_dds_party_transaction set created_date = coalesce(lastmodified_date, cast(transaction_date as timestamp)) where created_date is null; + update m_dds_collection_entry set createdby_id = 1 where createdby_id is null; + update m_dds_collection_entry set created_date = coalesce(lastmodified_date, current_timestamp) where created_date is null; + update m_dds_batch_run set createdby_id = 1 where createdby_id is null; + update m_dds_batch_run set created_date = coalesce(lastmodified_date, current_timestamp) where created_date is null; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + update m_dds_party p + set account_type_id = (select t.id from m_dds_account_type t where t.account_type_code = upper(trim(p.sub_type))) + where p.account_type_id is null; + update m_dds_party + set currency_code = (select code from m_organisation_currency order by id limit 1) + where currency_code is null; + update m_dds_party set activatedon_date = opening_date where activatedon_date is null; + update m_dds_party set activatedon_userid = createdby_id where activatedon_userid is null; + update m_dds_party set closedon_userid = coalesce(lastmodifiedby_id, createdby_id) + where closedon_userid is null and close_date is not null; + update m_dds_party p + set office_id = (select a.office_id from m_dds_agent a where a.id = p.agent_id) + where p.office_id is null; + + + + + + select count(*) from m_dds_party where account_type_id is null or currency_code is null or office_id is null + + + + + + + + + + + + + + + + + + + select count(*) from m_dds_account_type where deposit_liability_gl_id is null or interest_expense_gl_id is null or commission_expense_gl_id is null + + + + + + + + + + + + + + update m_dds_party_transaction set ref_no = 'DDST' || lpad(cast(id as varchar), 20, '0') where ref_no is null; + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0624_dds_account_number_serial.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0624_dds_account_number_serial.xml new file mode 100644 index 000000000..0b9896636 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0624_dds_account_number_serial.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + update m_dds_party set account_no = 'TMP' || cast(id as varchar) where account_no is not null; + update m_dds_party p + set account_no = 'DDS' || lpad(cast(r.serial as varchar), 9, '0') + from (select id, row_number() over (order by id) as serial from m_dds_party) r + where r.id = p.id; + update m_dds_account_number_serial set last_value = (select count(*) from m_dds_party) where id = 1; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0625_dds_default_commission_slabs.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0625_dds_default_commission_slabs.xml new file mode 100644 index 000000000..78d9b8bb7 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0625_dds_default_commission_slabs.xml @@ -0,0 +1,57 @@ + + + + + + + + select count(*) from m_dds_commission_slab where slab_type = 'ACCOUNT' + + + insert into m_dds_commission_slab (slab_type, slab_from, slab_to, commission_percent, createdby_id, created_date, + lastmodifiedby_id, lastmodified_date) + values + ('ACCOUNT', 0.00, 5000.00, 1.50, 1, current_timestamp, 1, current_timestamp), + ('ACCOUNT', 5000.01, 10000.00, 2.00, 1, current_timestamp, 1, current_timestamp), + ('ACCOUNT', 10000.01, 25000.00, 2.25, 1, current_timestamp, 1, current_timestamp), + ('ACCOUNT', 25000.01, 50000.00, 2.50, 1, current_timestamp, 1, current_timestamp), + ('ACCOUNT', 50000.01, 75000.00, 2.75, 1, current_timestamp, 1, current_timestamp), + ('ACCOUNT', 75000.01, 100000.00, 3.00, 1, current_timestamp, 1, current_timestamp), + ('ACCOUNT', 100000.01, 200000.00, 3.25, 1, current_timestamp, 1, current_timestamp), + ('ACCOUNT', 200000.01, 500000.00, 3.50, 1, current_timestamp, 1, current_timestamp), + ('ACCOUNT', 500000.01, 1000000.00, 3.75, 1, current_timestamp, 1, current_timestamp), + ('ACCOUNT', 1000000.01, 9999999999.00, 4.00, 1, current_timestamp, 1, current_timestamp); + + + +