Compare commits

..
Author SHA1 Message Date
keshav 1a2a945464 Merge branch 'origin/dev' into dev
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-25 11:32:55 +05:30
keshav 73ec85cfd6 feat: implement account register, branch day, voucher verification, and share dividend backend APIs 2026-08-25 11:31:49 +05:30
niteshandClaude Sonnet 5 46f5a1babd feat(deposits): FD in-place renewal, senior-citizen rate persistence, and Payment action
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
WHAT THIS CHANGES
==================
Three related FD (Fixed Deposit) features, built incrementally in one session:

1. IN-PLACE RENEWAL -- when an FD matures with "Re-Invest Principal and
   Interest", the SAME m_savings_account row is now reused across every
   renewal (account number never changes) instead of closing the account
   and opening a new one. FixedDepositAccount.renewInPlace() does this by
   setting start_interest_calculation_date as a pivot: interest for terms
   before the pivot is never recalculated, only the new term's window is
   computed from there. A new one-to-many audit table m_fd_renewal_history
   records one row per term (renewal_no 0,1,2...) with that term's own
   principal, rate, dates, interest earned, maturity, and status. A
   companion table m_fd_renewal_chart_slab_history archives each closing
   term's full rate-by-tenure chart, captured just before renewInPlace
   overwrites it.

2. SENIOR-CITIZEN RATE -- the CBS Base Interest Rate master
   (m_cbs_base_interest_rate_slab_history) was the only place that ever
   stored a senior-citizen premium. It is now also persisted on
   m_savings_account_interest_rate_slab (new senior_citizen_rate column,
   nullable, added only to this account-level table -- NOT to the shared
   InterestRateChartSlabFields embeddable, so the product-level table
   m_interest_rate_slab is untouched). Applied both at account creation
   (DepositAccountAssembler now prices a new FD off the active CBS base
   version when one exists for that FD type, falling back to the
   product's own chart otherwise) and at renewal (renewInPlace refreshes
   the chart from the same base version).

3. PAYMENT ACTION -- a new manual command, POST
   /fixeddepositaccounts/{id}?command=payment, available whether the FD
   is ACTIVE or MATURED, unlike Close (Matured-only) and Premature Close
   (Active-only, no reinvest). Offers: Transfer to Savings, Re-Invest
   Principal and Interest (via renewInPlace), Re-Invest Principal Only
   (legacy close-and-reopen, now also transfers the leftover interest to
   a chosen savings account instead of stranding it), and a new closure
   type PARTIAL_REINVEST (500) -- part of the matured balance renews in
   place on the same account, the remainder transfers to a savings
   account picked at request time. Withdraw Deposit is deliberately
   excluded from this command. New permission PAYMENT_FIXEDDEPOSITACCOUNT
   plus its CHECKER twin were added in migration 0478.

KEY FILES
=========
- DepositAccountDomainServiceJpa.java: handleFDAccountPayment (new),
  renewInPlace call sites, recordInPlaceRenewal, senior-citizen rate
  matching with unit conversion across days/weeks and months/years
  families.
- FixedDepositAccount.java: renewInPlace, validateRenewalDate,
  isLockedOnDate, fdAccountNumber/renewalCycleNo chain tracking.
- SavingsAccount.java (SHARED with Savings and RD): added
  findLastTransactionBefore -- the interest-posting opening balance for a
  pivot-dated account now seeds from the last transaction strictly before
  the pivot, not the one on it. This fixes a bug where a renewed term
  could accrue zero interest, or double-subtract a same-day withdrawal,
  depending on transaction list order. Also widened
  calculateDateAccountIsLockedUntil to protected so renewInPlace can
  refresh lock-in on each renewed term.
- DepositAccountTransactionDataValidator.java: new validatePayment.
  validateClosing now explicitly rejects PARTIAL_REINVEST (500) so it can
  never reach the Close or Premature-Close handlers, which have no branch
  for it and would otherwise pay the whole balance out in cash. This
  applies to both FD and RD, since they share validateClosing.
- DepositAccountReadPlatformServiceImpl.java: retrieveOneWithClosureTemplate
  (Close and Premature Close, excludes PARTIAL_REINVEST) split from the
  new retrieveOneWithPaymentTemplate (Payment, includes it), sharing one
  private method with a flag. The renewal-history fallback query, for
  chains that predate m_fd_renewal_history, was rewritten to emit the
  exact same column shape as the primary query.
- New: FixedDepositRenewalHistory plus its repository,
  FixedDepositRenewalChartSlabHistory plus its repository,
  PaymentFixedDepositAccountCommandHandler.
- Migrations 0471 through 0478 under tenant/parts, each registered once
  in changelog-tenant.xml.

RD AND SAVINGS IMPACT -- READ THIS IF SOMETHING BREAKS THERE
============================================================
RD and plain Savings share several of the touched files. Verified during
this work:
  - handleRDAccountClosure and RecurringDepositAccount.java were not
    modified.
  - PARTIAL_REINVEST (500) is now rejected by validateClosing for both FD
    and RD close and prematureClose commands, and excluded from their
    template option lists. This was a real regression caught mid-session:
    the enum value briefly leaked into RD's Close dropdown as a working
    looking option that actually paid the whole RD balance out in cash.
    It was found and fixed before this commit.
  - The SavingsAccount.calculateInterestUsing opening-balance change only
    affects accounts where start_interest_calculation_date differs from
    activatedon_date. On the dev DB this was exactly two accounts, both
    FD test accounts created this session. Before deploying, run:
      SELECT deposit_type_enum, COUNT(*) FROM m_savings_account
      WHERE start_interest_calculation_date IS NOT NULL
        AND start_interest_calculation_date <> activatedon_date
      GROUP BY deposit_type_enum;
    Any non-FD rows (100 or 300) there mean a Savings or RD account will
    get a different, corrected interest amount than before. This is
    expected to be rare or zero, but confirm before deploy.
  - DepositAccountAssembler's creation-time base-rate pricing is guarded
    by depositAccountType.isFixedDeposit, so RD account creation is
    unaffected.
  - is_active on InterestRateChart (migration 0474) is read only by
    FixedDepositProduct.activeChart, which only FD's renewInPlace and
    reInvest call. RD's applicableChart path is untouched. Migration 0474
    also retires (is_active = false) any chart whose end_date is already
    past. On the dev DB this retired zero rows; check on the target DB
    before deploy, since there is currently no API to reactivate a
    retired chart.

KNOWN CAVEATS -- NOT YET DONE, FLAGGING RATHER THAN HIDING
============================================================
  - Migrations 0471 through 0477, the DDL ones, have never actually
    executed via Liquibase. They were mirrored by hand directly into the
    dev DB (ADD COLUMN IF NOT EXISTS, CREATE TABLE IF NOT EXISTS) so
    testing could proceed without a full rebuild each time. Liquibase
    will mark them MARK_RAN via preConditions on the dev DB's next real
    run, but their first genuine execution will be on whatever
    environment does not already have these columns and tables, such as
    a fresh QA or production database. Test a migration-from-scratch run
    before relying on this in production.
  - This code has not been compiled. Every new or changed method
    signature, import, and call site was manually cross-checked with
    grep against real declarations, but that is not a substitute for
    gradle actually compiling it. Run a full build before deploying and
    treat any compiler error as expected until confirmed clean.
  - DepositAccountAssembler pricing every new FD off the active CBS base
    rate master, instead of the product's own chart, whenever one exists
    is a real behaviour change affecting all new FD accounts of a type
    that has a base-rate version, not just senior-citizen ones. Flagging
    this for explicit product sign-off, not assuming it in this commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 16:44:11 +05:30
niteshandClaude Opus 5 20fe11e60d fix(deposits): correct FD reinvestment on maturity and unblock product edit submission
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Reinvestment on maturity was creating the new deposit with a zero balance, because it
sourced the amount from activateWithBalance() -- a guard that deliberately returns zero to
stop a manually opened FD from auto-crediting itself. It now carries over what reInvest()
actually put on the new account. Both legs of the renewal are also posted as an internal
transfer, so the funds move through the transfer-suspense GL instead of appearing to leave
and re-enter the cash vault. Reinvested accounts no longer fall back to a 0% rate when no
chart slab covers the tenure, and a senior-citizen depositor keeps their premium rate on the
renewed term.

Adds a reinvestment trail: the new account records which account it came from, and the
account view resolves both directions of the link so either side can be traced.

Also fixes product edit submission, which failed outright for any product carrying
payment-channel GL mappings: the pending-edit mapper read "glAccountId" while the payload
carries "fundSourceAccountId" (and "incomeAccountId" for fee/penalty arrays), leaving
gl_account_id null and tripping the satellite table's NOT NULL constraint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 12:57:02 +05:30
niteshandClaude Sonnet 5 eb1aa767f7 fix(deposits): enforce savings-only maturity transfers and fix duplicate GL posting on FD/RD closure
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Adds validation so Transfer-to-Savings can only target a real savings account,
with a proper user-facing message instead of the generic validation error.
Fixes three duplicate journal-entry bugs uncovered while testing FD/RD
maturity: the dormant-accounts job re-posting already-closed accounts
(escheat/setSubStatusInactive/setSubStatusDormant), and the maturity-closure
flow itself re-deriving accounting for a withdrawal that the account-transfer
service had already posted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 11:04:23 +05:30
nitesh 8724c54d9b Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
# Conflicts:
#	fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java
2026-08-19 10:50:25 +05:30
nitesh 5c0a482f49 feat(kyc): resolve DIP/MNRL demo verdicts locally, make the check toggleable
Every mobile-number lookup was calling out to a local mock HTTP service
on port 8090 that had to be started separately, so the check failed
with a connection error whenever that process wasn't running.

Ports the mock's full verdict table (suspected, disconnected,
suspected+disconnected, clean) into MobileLookupServiceImpl itself, so
the demo/test numbers resolve in-process with no HTTP call. Adds
fineract.mobile-lookup.enabled (default off) to skip the real DIP/MNRL
call entirely for local work; every number then resolves CLEAN and is
stamped LOOKUP_DISABLED_BY_CONFIG in the log so it's never mistaken
for a genuine pass. Must be turned back on before UAT/production.
2026-08-19 10:35:48 +05:30
nitesh 0e07e60328 feat(current-accounts): add document upload/list/download/delete endpoints
/v1/currentaccounts/{id}/documents had no handler at all -- the
account's own /v1/currentaccounts resource matches the request path
first and has no documents method, so it never fell through to the
generic /v1/{entityType}/{entityId}/documents resource that every
other entity type relies on. Current accounts got a 404 on every
Documents-tab request.

Delegates straight to the existing DocumentApiResource so current
accounts get identical behaviour to clients, loans, etc.
2026-08-19 10:35:35 +05:30
nitesh e227e8ae0f fix(savings): post GL entries for facility-charge collections at edit approval
Charges collected when a maker-checker Nominee/Digital-Feature edit is
approved deducted the account balance and appeared in the passbook,
but never posted a journal entry -- collectCharges() saved the charge
transaction directly and skipped the accounting bridge entirely, so
the GL never balanced against the subledger.

Also fixes an unrelated NPE this exposed: collectCharges() used
saveAll() instead of saveAllAndFlush(), so the new charge transaction
had no id yet when the journal-entry code tried to read it.
2026-08-19 10:35:18 +05:30
nitesh 231919b62b fix(savings): stop crediting accounts automatically on activation
Activation for Savings, RD, FD and Current accounts no longer books an
opening deposit or enforces a minimum opening balance -- accounts go
active at zero balance and a clerk deposits funds afterward as a
separate transaction, matching how these branches actually operate.

Fixed Deposit and Recurring Deposit have their own override of
activateWithBalance() that a prior fix to the shared SavingsAccount
base class missed, so they kept auto-depositing after Savings was
already fixed. Removing that auto-deposit exposed two follow-on bugs
in the FD/RD activation flow, now also fixed:
- the opening deposit's journal entries were posted twice (once by the
  deposit helper, once by the activation command's own posting call)
- FD/RD maturity amount was computed from the account's real (now
  empty) transaction history at activation and never recalculated once
  a deposit actually landed, so it showed no projected interest until
  the account was reloaded from a lucky code path
2026-08-19 10:34:54 +05:30
216 changed files with 10850 additions and 9711 deletions
@@ -437,6 +437,7 @@ public final class AccountingConstants {
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
@@ -664,78 +664,6 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createAgent() {
this.actionName = "CREATE";
this.entityName = "AGENT";
this.entityId = null;
this.href = "/dds/agents/template";
return this;
}
public CommandWrapperBuilder updateAgent(final Long agentId) {
this.actionName = "UPDATE";
this.entityName = "AGENT";
this.entityId = agentId;
this.href = "/dds/agents/" + agentId;
return this;
}
public CommandWrapperBuilder createParty() {
this.actionName = "CREATE";
this.entityName = "PARTY";
this.entityId = null;
this.href = "/dds/parties/template";
return this;
}
public CommandWrapperBuilder updateParty(final Long partyId) {
this.actionName = "UPDATE";
this.entityName = "PARTY";
this.entityId = partyId;
this.href = "/dds/parties/" + partyId;
return this;
}
public CommandWrapperBuilder closeParty(final Long partyId) {
this.actionName = "CLOSE";
this.entityName = "PARTY";
this.entityId = partyId;
this.href = "/dds/parties/" + partyId + "?command=close";
return this;
}
public CommandWrapperBuilder createPartyTransaction() {
this.actionName = "CREATE";
this.entityName = "PARTYTRANSACTION";
this.entityId = null;
this.href = "/dds/partytransactions/template";
return this;
}
public CommandWrapperBuilder createCommissionSlabs() {
this.actionName = "CREATE";
this.entityName = "COMMISSIONSLAB";
this.entityId = null;
this.href = "/dds/commissionslabs/template";
return this;
}
public CommandWrapperBuilder generateDdsBatchRun() {
this.actionName = "GENERATE";
this.entityName = "DDSBATCH";
this.entityId = null;
this.href = "/dds/batchruns/template";
return this;
}
public CommandWrapperBuilder postDdsBatchRun(final Long batchRunId) {
this.actionName = "POST";
this.entityName = "DDSBATCH";
this.entityId = batchRunId;
this.href = "/dds/batchruns/" + batchRunId;
return this;
}
public CommandWrapperBuilder createOfficeTransaction() {
this.actionName = "CREATE";
this.entityName = "OFFICETRANSACTION";
@@ -2969,6 +2897,15 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder paymentFixedDepositAccount(final Long accountId) {
this.actionName = "PAYMENT";
this.entityName = "FIXEDDEPOSITACCOUNT";
this.entityId = accountId;
this.savingsId = accountId;
this.href = "/fixeddepositaccounts/" + accountId + "?command=payment";
return this;
}
public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) {
this.actionName = "CALCULATEINTEREST";
this.entityName = "FIXEDDEPOSITACCOUNT";
@@ -130,6 +130,19 @@ public class DataValidatorBuilder {
this.dataValidationErrors.add(error);
}
/**
* Like {@link #failWithCode(String, Object...)}, but the caller's own message is what's actually shown to the
* user (that method always renders the generic "Failed data validation due to: {errorCode}." regardless of the
* args passed to it).
*/
public void failWithCodeAndMessage(final String errorCode, final String defaultUserMessage,
final Object... defaultUserMessageArgs) {
String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + "." + errorCode;
final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultUserMessage, this.parameter,
this.value, defaultUserMessageArgs);
this.dataValidationErrors.add(error);
}
public void failWithCodeNoParameterAddedToErrorCode(final String errorCode, final Object... defaultUserMessageArgs) {
String validationErrorCode = "validation.msg." + this.resource + "." + errorCode;
String defaultEnglishMessage = "Failed data validation due to: " + errorCode + ".";
@@ -39,6 +39,7 @@ public enum JobName {
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
@@ -1,148 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import lombok.Getter;
/**
* Immutable data object representing a DDS agent, including its collection-limit history and
* guarantors. Carries the collection/commission/security-account fields merged in from SubAgent
* (2026-08-24) - Agent now does the daily collection work directly.
*/
@Getter
public final class AgentData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final Long clientId;
private final String clientName;
private final String agentCode;
private final String agentNameId;
private final String agentAddress1;
private final String agentAddress2;
private final String agentCity;
private final String agentPinCode;
private final String agentTelephone;
private final String agentSavingAccountType;
private final String agentSavingAccountNo;
private final LocalDate agentOpeningDate;
private final LocalDate agentBirthDate;
private final BigDecimal agentCurrentBalance;
private final BigDecimal agentOpeningBalance;
private final String homeBranch;
private final String savingBranch;
private final Boolean tdsApplicable;
private final BigDecimal tdsPercent;
private final BigDecimal currentTds;
private final BigDecimal tdsAmount;
private final String transferBranch;
private final String transferAmountAccountType;
private final String transferAccountNo;
private final Boolean commissionCalculation;
private final BigDecimal limit;
private final BigDecimal machineRent;
private final String agentSecurityBranch;
private final String agentSecurityAccountType;
private final String agentSecurityAccountNo;
private final BigDecimal commission;
private final BigDecimal otherCommission;
private final BigDecimal agentDeposit;
private final BigDecimal festivalAdvance;
private final BigDecimal otherDeduction;
private final Collection<AgentLimitData> limits;
private final Collection<AgentGuarantorData> guarantors;
public static AgentData instance(final Long id, final Long clientId, final String clientName, final String agentCode,
final String agentNameId, final String agentAddress1, final String agentAddress2, final String agentCity,
final String agentPinCode, final String agentTelephone, final String agentSavingAccountType,
final String agentSavingAccountNo, final LocalDate agentOpeningDate, final LocalDate agentBirthDate,
final BigDecimal agentCurrentBalance, final BigDecimal agentOpeningBalance, final String homeBranch,
final String savingBranch, final Boolean tdsApplicable, final BigDecimal tdsPercent, final BigDecimal currentTds,
final BigDecimal tdsAmount, final String transferBranch, final String transferAmountAccountType,
final String transferAccountNo, final Boolean commissionCalculation, final BigDecimal limit, final BigDecimal machineRent,
final String agentSecurityBranch, final String agentSecurityAccountType, final String agentSecurityAccountNo,
final BigDecimal commission, final BigDecimal otherCommission, final BigDecimal agentDeposit,
final BigDecimal festivalAdvance, final BigDecimal otherDeduction, final Collection<AgentLimitData> limits,
final Collection<AgentGuarantorData> guarantors) {
return new AgentData(id, clientId, clientName, agentCode, agentNameId, agentAddress1, agentAddress2, agentCity, agentPinCode,
agentTelephone, agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, agentCurrentBalance,
agentOpeningBalance, homeBranch, savingBranch, tdsApplicable, tdsPercent, currentTds, tdsAmount, transferBranch,
transferAmountAccountType, transferAccountNo, commissionCalculation, limit, machineRent, agentSecurityBranch,
agentSecurityAccountType, agentSecurityAccountNo, commission, otherCommission, agentDeposit, festivalAdvance,
otherDeduction, limits, guarantors);
}
private AgentData(final Long id, final Long clientId, final String clientName, final String agentCode, final String agentNameId,
final String agentAddress1, final String agentAddress2, final String agentCity, final String agentPinCode,
final String agentTelephone, final String agentSavingAccountType, final String agentSavingAccountNo,
final LocalDate agentOpeningDate, final LocalDate agentBirthDate, final BigDecimal agentCurrentBalance,
final BigDecimal agentOpeningBalance, final String homeBranch, final String savingBranch, final Boolean tdsApplicable,
final BigDecimal tdsPercent, final BigDecimal currentTds, final BigDecimal tdsAmount, final String transferBranch,
final String transferAmountAccountType, final String transferAccountNo, final Boolean commissionCalculation,
final BigDecimal limit, final BigDecimal machineRent, final String agentSecurityBranch,
final String agentSecurityAccountType, final String agentSecurityAccountNo, final BigDecimal commission,
final BigDecimal otherCommission, final BigDecimal agentDeposit, final BigDecimal festivalAdvance,
final BigDecimal otherDeduction, final Collection<AgentLimitData> limits, final Collection<AgentGuarantorData> guarantors) {
this.id = id;
this.clientId = clientId;
this.clientName = clientName;
this.agentCode = agentCode;
this.agentNameId = agentNameId;
this.agentAddress1 = agentAddress1;
this.agentAddress2 = agentAddress2;
this.agentCity = agentCity;
this.agentPinCode = agentPinCode;
this.agentTelephone = agentTelephone;
this.agentSavingAccountType = agentSavingAccountType;
this.agentSavingAccountNo = agentSavingAccountNo;
this.agentOpeningDate = agentOpeningDate;
this.agentBirthDate = agentBirthDate;
this.agentCurrentBalance = agentCurrentBalance;
this.agentOpeningBalance = agentOpeningBalance;
this.homeBranch = homeBranch;
this.savingBranch = savingBranch;
this.tdsApplicable = tdsApplicable;
this.tdsPercent = tdsPercent;
this.currentTds = currentTds;
this.tdsAmount = tdsAmount;
this.transferBranch = transferBranch;
this.transferAmountAccountType = transferAmountAccountType;
this.transferAccountNo = transferAccountNo;
this.commissionCalculation = commissionCalculation;
this.limit = limit;
this.machineRent = machineRent;
this.agentSecurityBranch = agentSecurityBranch;
this.agentSecurityAccountType = agentSecurityAccountType;
this.agentSecurityAccountNo = agentSecurityAccountNo;
this.commission = commission;
this.otherCommission = otherCommission;
this.agentDeposit = agentDeposit;
this.festivalAdvance = festivalAdvance;
this.otherDeduction = otherDeduction;
this.limits = limits;
this.guarantors = guarantors;
}
}
@@ -1,59 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.data;
import java.io.Serial;
import java.io.Serializable;
import lombok.Getter;
/**
* Immutable data object representing a single agent guarantor.
*/
@Getter
public final class AgentGuarantorData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final Long clientId;
private final String guarantorName;
private final String address1;
private final String address2;
private final String city;
private final String pinCode;
private final String telephone;
public static AgentGuarantorData instance(final Long id, final Long clientId, final String guarantorName, final String address1,
final String address2, final String city, final String pinCode, final String telephone) {
return new AgentGuarantorData(id, clientId, guarantorName, address1, address2, city, pinCode, telephone);
}
private AgentGuarantorData(final Long id, final Long clientId, final String guarantorName, final String address1,
final String address2, final String city, final String pinCode, final String telephone) {
this.id = id;
this.clientId = clientId;
this.guarantorName = guarantorName;
this.address1 = address1;
this.address2 = address2;
this.city = city;
this.pinCode = pinCode;
this.telephone = telephone;
}
}
@@ -1,48 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
@Getter
public final class AgentLimitData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final LocalDate fromDate;
private final LocalDate toDate;
private final BigDecimal limit;
public static AgentLimitData instance(final Long id, final LocalDate fromDate, final LocalDate toDate, final BigDecimal limit) {
return new AgentLimitData(id, fromDate, toDate, limit);
}
private AgentLimitData(final Long id, final LocalDate fromDate, final LocalDate toDate, final BigDecimal limit) {
this.id = id;
this.fromDate = fromDate;
this.toDate = toDate;
this.limit = limit;
}
}
@@ -1,440 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.domain;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.portfolio.client.domain.Client;
/**
* A DDS (Daily Deposit Scheme) collection agent. Can represent either a person or, at the top of the
* hierarchy, the bank itself acting as its own agent. Holds its own settlement savings account and one
* or more guarantors.
*/
@Getter
@Entity
@Table(name = "m_dds_agent", uniqueConstraints = { @UniqueConstraint(columnNames = { "agent_code" }, name = "agent_code_UNIQUE") })
public class Agent extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@Column(name = "agent_code", length = 20, nullable = false)
private String agentCode;
@Column(name = "agent_name", length = 100, nullable = false)
private String agentName;
@Column(name = "agent_address1", length = 200, nullable = false)
private String agentAddress1;
@Column(name = "agent_address2", length = 200)
private String agentAddress2;
@Column(name = "agent_city", length = 100, nullable = false)
private String agentCity;
@Column(name = "agent_pincode", length = 20, nullable = false)
private String agentPinCode;
@Column(name = "agent_telephone", length = 20)
private String agentTelephone;
@Column(name = "agent_saving_account_type", length = 100, nullable = false)
private String agentSavingAccountType;
@Column(name = "agent_saving_account_no", length = 50, nullable = false)
private String agentSavingAccountNo;
@Column(name = "agent_opening_date", nullable = false)
private LocalDate agentOpeningDate;
@Column(name = "agent_birth_date", nullable = false)
private LocalDate agentBirthDate;
@Column(name = "agent_current_balance", scale = 6, precision = 19, nullable = false)
private BigDecimal agentCurrentBalance;
@Column(name = "agent_opening_balance", scale = 6, precision = 19, nullable = false)
private BigDecimal agentOpeningBalance;
// Fields below were merged in from SubAgent (2026-08-24) - the Agent itself now does the daily
// collection work directly; SubAgent no longer exists as a separate tier. Ag.Level / Upper Ag.Level
// / Collector (MLM) / CRPB Points / Level Update Date were dropped - they only ever meant anything
// for a multi-level hierarchy, which no longer exists here.
@Column(name = "home_branch", length = 100, nullable = false)
private String homeBranch;
@Column(name = "saving_branch", length = 100, nullable = false)
private String savingBranch;
@Column(name = "tds_applicable", nullable = false)
private boolean tdsApplicable;
@Column(name = "tds_percent", scale = 6, precision = 19)
private BigDecimal tdsPercent;
@Column(name = "current_tds", scale = 6, precision = 19, nullable = false)
private BigDecimal currentTds;
@Column(name = "tds_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal tdsAmount;
@Column(name = "transfer_branch", length = 100)
private String transferBranch;
@Column(name = "transfer_amount_account_type", length = 100)
private String transferAmountAccountType;
@Column(name = "transfer_account_no", length = 50)
private String transferAccountNo;
@Column(name = "collector_commission_calculation", nullable = false)
private boolean commissionCalculation;
@Column(name = "collector_limit_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal limitAmount;
@Column(name = "machine_rent", scale = 6, precision = 19, nullable = false)
private BigDecimal machineRent;
@Column(name = "agent_security_branch", length = 100)
private String agentSecurityBranch;
@Column(name = "agent_security_account_type", length = 100)
private String agentSecurityAccountType;
@Column(name = "agent_security_account_no", length = 50)
private String agentSecurityAccountNo;
@Column(name = "commission", scale = 6, precision = 19, nullable = false)
private BigDecimal commission;
@Column(name = "other_commission", scale = 6, precision = 19, nullable = false)
private BigDecimal otherCommission;
@Column(name = "agent_deposit", scale = 6, precision = 19, nullable = false)
private BigDecimal agentDeposit;
@Column(name = "festival_advance", scale = 6, precision = 19, nullable = false)
private BigDecimal festivalAdvance;
@Column(name = "other_deduction", scale = 6, precision = 19, nullable = false)
private BigDecimal otherDeduction;
@Column(name = "last_commission_date")
private LocalDate lastCommissionDate;
@OneToMany(mappedBy = "agent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("id")
private List<AgentGuarantor> guarantors = new ArrayList<>();
@OneToMany(mappedBy = "agent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("id")
private List<AgentLimit> limits = new ArrayList<>();
protected Agent() {
//
}
private Agent(final Client client, final String agentCode, final String agentName, final String agentAddress1,
final String agentAddress2, final String agentCity, final String agentPinCode, final String agentTelephone,
final String agentSavingAccountType, final String agentSavingAccountNo, final LocalDate agentOpeningDate,
final LocalDate agentBirthDate, final BigDecimal agentOpeningBalance, final JsonCommand command) {
this.client = client;
this.agentCode = agentCode;
this.agentName = agentName;
this.agentAddress1 = agentAddress1;
this.agentAddress2 = StringUtils.defaultIfEmpty(agentAddress2, null);
this.agentCity = agentCity;
this.agentPinCode = agentPinCode;
this.agentTelephone = StringUtils.defaultIfEmpty(agentTelephone, null);
this.agentSavingAccountType = agentSavingAccountType;
this.agentSavingAccountNo = agentSavingAccountNo;
this.agentOpeningDate = agentOpeningDate;
this.agentBirthDate = agentBirthDate;
this.agentOpeningBalance = agentOpeningBalance == null ? BigDecimal.ZERO : agentOpeningBalance;
this.agentCurrentBalance = this.agentOpeningBalance;
this.homeBranch = command.stringValueOfParameterNamed("homeBranch");
this.savingBranch = command.stringValueOfParameterNamed("savingBranch");
this.tdsApplicable = command.booleanPrimitiveValueOfParameterNamed("tdsApplicable");
this.tdsPercent = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("tdsPercent");
this.currentTds = BigDecimal.ZERO;
this.tdsAmount = BigDecimal.ZERO;
this.transferBranch = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("transferBranch"), null);
this.transferAmountAccountType = StringUtils
.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("transferAmountAccountType"), null);
this.transferAccountNo = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("transferAccountNo"), null);
this.commissionCalculation = command.booleanPrimitiveValueOfParameterNamed("commissionCalculation");
this.limitAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("limit"));
this.machineRent = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("machineRent"));
this.agentSecurityBranch = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("agentSecurityBranch"),
null);
this.agentSecurityAccountType = StringUtils
.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("agentSecurityAccountType"), null);
this.agentSecurityAccountNo = StringUtils.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("agentSecurityAccountNo"),
null);
this.commission = BigDecimal.ZERO;
this.otherCommission = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("otherCommission"));
this.agentDeposit = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("agentDeposit"));
this.festivalAdvance = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("festivalAdvance"));
this.otherDeduction = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("otherDeduction"));
}
public static Agent fromJson(final Client client, final String agentCode, final JsonCommand command) {
final String agentName = command.stringValueOfParameterNamed("agentNameId");
final String agentAddress1 = command.stringValueOfParameterNamed("agentAddress1");
final String agentAddress2 = command.stringValueOfParameterNamedAllowingNull("agentAddress2");
final String agentCity = command.stringValueOfParameterNamed("agentCity");
final String agentPinCode = command.stringValueOfParameterNamed("agentPinCode");
final String agentTelephone = command.stringValueOfParameterNamedAllowingNull("agentTelephone");
final String agentSavingAccountType = command.stringValueOfParameterNamed("agentSavingAccountType");
final String agentSavingAccountNo = command.stringValueOfParameterNamed("agentSavingAccountNo");
final LocalDate agentOpeningDate = command.localDateValueOfParameterNamed("agentOpeningDate");
final LocalDate agentBirthDate = command.localDateValueOfParameterNamed("agentBirthDate");
final BigDecimal agentOpeningBalance = command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("agentOpeningBalance");
return new Agent(client, agentCode, agentName, agentAddress1, agentAddress2, agentCity, agentPinCode, agentTelephone,
agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, agentOpeningBalance, command);
}
private static BigDecimal orZero(final BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
public void addGuarantor(final AgentGuarantor guarantor) {
guarantor.setAgent(this);
this.guarantors.add(guarantor);
}
public void resetGuarantors() {
this.guarantors.clear();
}
public void addLimit(final AgentLimit limit) {
limit.setAgent(this);
this.limits.add(limit);
}
public void resetLimits() {
this.limits.clear();
}
/**
* Adds a Commission Posting's amount to this agent's running commission total and stamps the date
* commission was last calculated through, so the next Commission Calculation batch only covers
* collections since then.
*/
public void postCommission(final BigDecimal amount, final LocalDate throughDate) {
this.commission = this.commission.add(amount);
this.lastCommissionDate = throughDate;
}
/**
* Cash this agent has collected from parties but not yet handed over to a branch (an Agent Tran
* settlement). Raised by a Party Tran collection, lowered by an Agent Tran settlement - this is the
* figure an Agent's collection limit is enforced against.
*/
public BigDecimal credit(final BigDecimal amount) {
this.agentCurrentBalance = this.agentCurrentBalance.add(amount);
return this.agentCurrentBalance;
}
public BigDecimal settle(final BigDecimal amount) {
this.agentCurrentBalance = this.agentCurrentBalance.subtract(amount);
return this.agentCurrentBalance;
}
public void changeClient(final Client newClient) {
this.client = newClient;
}
public Long clientId() {
return this.client.getId();
}
public Map<String, Object> update(final JsonCommand command) {
final Map<String, Object> actualChanges = new LinkedHashMap<>(10);
if (command.isChangeInLongParameterNamed("clientId", this.client.getId())) {
actualChanges.put("clientId", command.longValueOfParameterNamed("clientId"));
}
if (command.isChangeInStringParameterNamed("agentNameId", this.agentName)) {
final String newValue = command.stringValueOfParameterNamed("agentNameId");
actualChanges.put("agentNameId", newValue);
this.agentName = newValue;
}
if (command.isChangeInStringParameterNamed("agentAddress1", this.agentAddress1)) {
final String newValue = command.stringValueOfParameterNamed("agentAddress1");
actualChanges.put("agentAddress1", newValue);
this.agentAddress1 = newValue;
}
if (command.isChangeInStringParameterNamed("agentAddress2", this.agentAddress2)) {
final String newValue = command.stringValueOfParameterNamed("agentAddress2");
actualChanges.put("agentAddress2", newValue);
this.agentAddress2 = StringUtils.defaultIfEmpty(newValue, null);
}
if (command.isChangeInStringParameterNamed("agentCity", this.agentCity)) {
final String newValue = command.stringValueOfParameterNamed("agentCity");
actualChanges.put("agentCity", newValue);
this.agentCity = newValue;
}
if (command.isChangeInStringParameterNamed("agentPinCode", this.agentPinCode)) {
final String newValue = command.stringValueOfParameterNamed("agentPinCode");
actualChanges.put("agentPinCode", newValue);
this.agentPinCode = newValue;
}
if (command.isChangeInStringParameterNamed("agentTelephone", this.agentTelephone)) {
final String newValue = command.stringValueOfParameterNamed("agentTelephone");
actualChanges.put("agentTelephone", newValue);
this.agentTelephone = StringUtils.defaultIfEmpty(newValue, null);
}
if (command.isChangeInStringParameterNamed("agentSavingAccountType", this.agentSavingAccountType)) {
final String newValue = command.stringValueOfParameterNamed("agentSavingAccountType");
actualChanges.put("agentSavingAccountType", newValue);
this.agentSavingAccountType = newValue;
}
if (command.isChangeInStringParameterNamed("agentSavingAccountNo", this.agentSavingAccountNo)) {
final String newValue = command.stringValueOfParameterNamed("agentSavingAccountNo");
actualChanges.put("agentSavingAccountNo", newValue);
this.agentSavingAccountNo = newValue;
}
if (command.isChangeInDateParameterNamed("agentOpeningDate", this.agentOpeningDate)) {
actualChanges.put("agentOpeningDate", command.stringValueOfParameterNamed("agentOpeningDate"));
this.agentOpeningDate = command.localDateValueOfParameterNamed("agentOpeningDate");
}
if (command.isChangeInDateParameterNamed("agentBirthDate", this.agentBirthDate)) {
actualChanges.put("agentBirthDate", command.stringValueOfParameterNamed("agentBirthDate"));
this.agentBirthDate = command.localDateValueOfParameterNamed("agentBirthDate");
}
stringChange(command, actualChanges, "homeBranch", this.homeBranch, v -> this.homeBranch = v);
stringChange(command, actualChanges, "savingBranch", this.savingBranch, v -> this.savingBranch = v);
if (command.isChangeInBooleanParameterNamed("tdsApplicable", this.tdsApplicable)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("tdsApplicable");
actualChanges.put("tdsApplicable", newValue);
this.tdsApplicable = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("tdsPercent", this.tdsPercent)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("tdsPercent");
actualChanges.put("tdsPercent", newValue);
this.tdsPercent = newValue;
}
stringChange(command, actualChanges, "transferBranch", this.transferBranch,
v -> this.transferBranch = StringUtils.defaultIfEmpty(v, null));
stringChange(command, actualChanges, "transferAmountAccountType", this.transferAmountAccountType,
v -> this.transferAmountAccountType = StringUtils.defaultIfEmpty(v, null));
stringChange(command, actualChanges, "transferAccountNo", this.transferAccountNo,
v -> this.transferAccountNo = StringUtils.defaultIfEmpty(v, null));
if (command.isChangeInBooleanParameterNamed("commissionCalculation", this.commissionCalculation)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("commissionCalculation");
actualChanges.put("commissionCalculation", newValue);
this.commissionCalculation = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("limit", this.limitAmount)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("limit");
actualChanges.put("limit", newValue);
this.limitAmount = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("machineRent", this.machineRent)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("machineRent");
actualChanges.put("machineRent", newValue);
this.machineRent = newValue;
}
stringChange(command, actualChanges, "agentSecurityBranch", this.agentSecurityBranch,
v -> this.agentSecurityBranch = StringUtils.defaultIfEmpty(v, null));
stringChange(command, actualChanges, "agentSecurityAccountType", this.agentSecurityAccountType,
v -> this.agentSecurityAccountType = StringUtils.defaultIfEmpty(v, null));
stringChange(command, actualChanges, "agentSecurityAccountNo", this.agentSecurityAccountNo,
v -> this.agentSecurityAccountNo = StringUtils.defaultIfEmpty(v, null));
if (command.isChangeInBigDecimalParameterNamed("otherCommission", this.otherCommission)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("otherCommission");
actualChanges.put("otherCommission", newValue);
this.otherCommission = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("agentDeposit", this.agentDeposit)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("agentDeposit");
actualChanges.put("agentDeposit", newValue);
this.agentDeposit = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("festivalAdvance", this.festivalAdvance)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("festivalAdvance");
actualChanges.put("festivalAdvance", newValue);
this.festivalAdvance = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("otherDeduction", this.otherDeduction)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("otherDeduction");
actualChanges.put("otherDeduction", newValue);
this.otherDeduction = newValue;
}
return actualChanges;
}
private void stringChange(final JsonCommand command, final Map<String, Object> actualChanges, final String paramName,
final String existingValue, final java.util.function.Consumer<String> setter) {
if (command.isChangeInStringParameterNamed(paramName, existingValue)) {
final String newValue = command.stringValueOfParameterNamed(paramName);
actualChanges.put(paramName, newValue);
setter.accept(newValue);
}
}
}
@@ -1,83 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* A single guarantor recorded against an {@link Agent} (or, reused with the same shape, against a
* SubAgent). Always created/replaced as part of its parent agent's own create/update command - it has
* no independent API of its own.
*/
@Getter
@Entity
@Table(name = "m_dds_agent_guarantor")
public class AgentGuarantor extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "agent_id", nullable = false)
private Agent agent;
@Column(name = "client_id", nullable = false)
private Long clientId;
@Column(name = "guarantor_name", length = 100, nullable = false)
private String guarantorName;
@Column(name = "address1", length = 200, nullable = false)
private String address1;
@Column(name = "address2", length = 200)
private String address2;
@Column(name = "city", length = 100, nullable = false)
private String city;
@Column(name = "pin_code", length = 20, nullable = false)
private String pinCode;
@Column(name = "telephone", length = 20)
private String telephone;
protected AgentGuarantor() {
//
}
public AgentGuarantor(final Long clientId, final String guarantorName, final String address1, final String address2,
final String city, final String pinCode, final String telephone) {
this.clientId = clientId;
this.guarantorName = guarantorName;
this.address1 = address1;
this.address2 = StringUtils.defaultIfEmpty(address2, null);
this.city = city;
this.pinCode = pinCode;
this.telephone = StringUtils.defaultIfEmpty(telephone, null);
}
void setAgent(final Agent agent) {
this.agent = agent;
}
}
@@ -1,66 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* One row of an Agent's time-bound collection-limit history (From Date / To Date / Limit) - an
* Agent's authorized collection limit can change over time and every change is kept, not overwritten.
*/
@Getter
@Entity
@Table(name = "m_dds_agent_limit")
public class AgentLimit extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "agent_id", nullable = false)
private Agent agent;
@Column(name = "from_date", nullable = false)
private LocalDate fromDate;
@Column(name = "to_date", nullable = false)
private LocalDate toDate;
@Column(name = "limit_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal limitAmount;
protected AgentLimit() {
//
}
public AgentLimit(final LocalDate fromDate, final LocalDate toDate, final BigDecimal limitAmount) {
this.fromDate = fromDate;
this.toDate = toDate;
this.limitAmount = limitAmount;
}
void setAgent(final Agent agent) {
this.agent = agent;
}
}
@@ -1,40 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
/**
* A {@link RuntimeException} thrown when a DDS agent resource is not found.
*/
public class AgentNotFoundException extends AbstractPlatformResourceNotFoundException {
public AgentNotFoundException(final Long id) {
super("error.msg.agent.id.invalid", "Agent with identifier " + id + " does not exist", id);
}
public AgentNotFoundException(final Long id, final EmptyResultDataAccessException e) {
super("error.msg.agent.id.invalid", "Agent with identifier " + id + " does not exist", id, e);
}
public AgentNotFoundException(final String agentCode) {
super("error.msg.agent.code.invalid", "Agent with code " + agentCode + " does not exist", agentCode);
}
}
@@ -1,39 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public final class CommissionSlabData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final String slabType;
private final BigDecimal slabFrom;
private final BigDecimal slabTo;
private final BigDecimal commissionPercent;
}
@@ -1,61 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* One row of the agent commission chart: an ACCOUNT slab (commission percentage on collection
* volume) or a COMMISSION slab (the override a senior agent earns on a sub-agent's commission).
* Append-only reference data, like the legacy screen it replaces - there is deliberately no update or
* delete API; a superseded slab range is retired by adding a new row, not editing history.
*/
@Getter
@Entity
@Table(name = "m_dds_commission_slab")
public class CommissionSlab extends AbstractPersistableCustom<Long> {
@Column(name = "slab_type", length = 20, nullable = false)
private String slabType;
@Column(name = "slab_from", scale = 6, precision = 19, nullable = false)
private BigDecimal slabFrom;
@Column(name = "slab_to", scale = 6, precision = 19, nullable = false)
private BigDecimal slabTo;
@Column(name = "commission_percent", scale = 6, precision = 19, nullable = false)
private BigDecimal commissionPercent;
protected CommissionSlab() {
//
}
public CommissionSlab(final String slabType, final BigDecimal slabFrom, final BigDecimal slabTo, final BigDecimal commissionPercent) {
this.slabType = slabType;
this.slabFrom = slabFrom;
this.slabTo = slabTo;
this.commissionPercent = commissionPercent;
}
}
@@ -72,11 +72,26 @@ public class SavingsAccountSummaryData {
// differentiate deposit accounts Savings, FD and RD accounts
private final EnumOptionData depositType;
/**
* For a Fixed Deposit, the stable customer-facing FD number: the account number of the first account in the renewal
* chain, which stays the same across every renewal even though each term is its own account row. Equals accountNo
* for an FD that has never been renewed, and is null for non-deposit accounts.
*/
private final String fdAccountNumber;
/**
* Which renewal cycle of that FD this row is: 0 for the originally opened account, 1 for the first renewal, and so
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
*/
private final Integer renewalCycleNo;
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate) {
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
final String fdAccountNumber, final Integer renewalCycleNo) {
this.fdAccountNumber = fdAccountNumber;
this.renewalCycleNo = renewalCycleNo;
this.id = id;
this.accountNo = accountNo;
this.externalId = externalId;
@@ -1,50 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder(toBuilder = true)
public final class BatchRunData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final String jobType;
private final String branchScope;
private final LocalDate jobDate;
private final Long agentFrom;
private final Long agentTo;
private final Long partyFrom;
private final Long partyTo;
private final String status;
private final Integer totalRows;
private final BigDecimal totalAmount;
private final LocalDate generatedOnDate;
private final LocalDate postedOnDate;
private final Collection<BatchRunRowData> rows;
}
@@ -1,41 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public final class BatchRunRowData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final Long partyId;
private final String partyCode;
private final String partyName;
private final Long agentId;
private final String agentCode;
private final BigDecimal amount;
}
@@ -1,122 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.domain;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* One Calculate-then-Post batch run - the deliberate two-step control from the legacy Interest Part
* menu (Interest Calculation -> Interest Posting, OD Interest Calculation -> OD Interest Posting,
* Commission Calculation -> Commission Posting): a run is generated (rows computed and saved as a
* preview, nothing else changes), then posted (a separate, explicit step that applies exactly the
* rows that were previewed - never a fresh recalculation, so what gets applied is always what was
* seen).
*/
@Getter
@Entity
@Table(name = "m_dds_batch_run")
public class BatchRun extends AbstractPersistableCustom<Long> {
@Column(name = "job_type", length = 40, nullable = false)
private String jobType;
@Column(name = "branch_scope", length = 20, nullable = false)
private String branchScope;
@Column(name = "job_date", nullable = false)
private LocalDate jobDate;
@Column(name = "agent_from", nullable = false)
private Long agentFrom;
@Column(name = "agent_to", nullable = false)
private Long agentTo;
@Column(name = "party_from")
private Long partyFrom;
@Column(name = "party_to")
private Long partyTo;
@Column(name = "status", length = 20, nullable = false)
private String status;
@Column(name = "total_rows", nullable = false)
private Integer totalRows;
@Column(name = "total_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal totalAmount;
@Column(name = "generated_on_date", nullable = false)
private LocalDate generatedOnDate;
@Column(name = "posted_on_date")
private LocalDate postedOnDate;
@OneToMany(mappedBy = "batchRun", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("id")
private List<BatchRunRow> rows = new ArrayList<>();
protected BatchRun() {
//
}
public BatchRun(final String jobType, final String branchScope, final LocalDate jobDate, final Long agentFrom,
final Long agentTo, final Long partyFrom, final Long partyTo, final LocalDate generatedOnDate) {
this.jobType = jobType;
this.branchScope = branchScope;
this.jobDate = jobDate;
this.agentFrom = agentFrom;
this.agentTo = agentTo;
this.partyFrom = partyFrom;
this.partyTo = partyTo;
this.status = "CALCULATED";
this.totalRows = 0;
this.totalAmount = BigDecimal.ZERO;
this.generatedOnDate = generatedOnDate;
}
public void addRow(final BatchRunRow row) {
row.setBatchRun(this);
this.rows.add(row);
this.totalRows = this.rows.size();
this.totalAmount = this.totalAmount.add(row.getAmount());
}
public boolean isPosted() {
return "POSTED".equals(this.status);
}
public void markPosted(final LocalDate postedOnDate) {
this.status = "POSTED";
this.postedOnDate = postedOnDate;
}
}
@@ -1,65 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* One previewed/posted line of a {@link BatchRun}: for an interest job, one Party's accrued interest;
* for a commission job, one Agent's commission on their collected volume (partyId is null there).
*/
@Getter
@Entity
@Table(name = "m_dds_batch_run_row")
public class BatchRunRow extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "batch_run_id", nullable = false)
private BatchRun batchRun;
@Column(name = "party_id")
private Long partyId;
@Column(name = "agent_id", nullable = false)
private Long agentId;
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
private BigDecimal amount;
protected BatchRunRow() {
//
}
public BatchRunRow(final Long partyId, final Long agentId, final BigDecimal amount) {
this.partyId = partyId;
this.agentId = agentId;
this.amount = amount;
}
void setBatchRun(final BatchRun batchRun) {
this.batchRun = batchRun;
}
}
@@ -1,32 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when Post is attempted on a batch run that has already been posted - a run applies once,
* matching the calculate-then-post safety control (never re-post the same preview).
*/
public class BatchRunAlreadyPostedException extends AbstractPlatformDomainRuleException {
public BatchRunAlreadyPostedException(final Long batchRunId) {
super("error.msg.ddsbatchrun.already.posted", "DDS batch run " + batchRunId + " has already been posted", batchRunId);
}
}
@@ -1,28 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class BatchRunNotFoundException extends AbstractPlatformResourceNotFoundException {
public BatchRunNotFoundException(final Long id) {
super("error.msg.ddsbatchrun.id.invalid", "DDS batch run with identifier " + id + " does not exist", id);
}
}
@@ -1,107 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import lombok.Builder;
import lombok.Getter;
/**
* Immutable data object representing a DDS Party (customer scheme account), including its nominees.
* Built via the Lombok builder rather than a long positional constructor - the field count here makes
* positional args unreadable and error-prone to keep in sync.
*/
@Getter
@Builder(toBuilder = true)
public final class PartyData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final Long clientId;
private final String clientName;
private final Long agentId;
private final String agentCode;
private final String agentName;
private final Long savingsAccountId;
private final String subType;
private final String partyCode;
private final String lastAccount;
private final Boolean nominal;
private final String aadharNo;
private final String partyName;
private final String partyName1;
private final String address1;
private final String address2;
private final String address3;
private final String city;
private final String pincode;
private final String gender;
private final String panCardNo;
private final String telephone;
private final String mobileNoSms;
private final String operation;
private final LocalDate birthDate;
private final String occupation;
private final String risk;
private final String ckycId;
private final String adultMinor;
private final LocalDate minorDate;
private final Long guardianClientId;
private final String minAccountOpt;
private final String relation;
private final LocalDate openingDate;
private final Integer month;
private final LocalDate maturityDate;
private final LocalDate reopenDate;
private final BigDecimal depositAmount;
private final BigDecimal dailyDepositAmount;
private final Integer days;
private final String ledgerNo;
private final BigDecimal maturityAmount;
private final BigDecimal interestRate;
private final String withdrawType;
private final Boolean od1Flag;
private final Boolean od2Flag;
private final Boolean lien;
private final LocalDate payDate;
private final BigDecimal paymentAmount;
private final BigDecimal currentBalance;
private final BigDecimal openingBalance;
private final Boolean isSmsActive;
private final Boolean isEmailActive;
private final BigDecimal interestCalc;
private final LocalDate interestDate;
private final String spDescription;
private final String freezeCode;
private final String advCode;
private final String creditToBranch;
private final String creditToAccountType;
private final String creditToAccount;
private final Boolean nomineeApplicable;
private final String nomineeSplitMode;
private final String status;
private final LocalDate closeDate;
private final Collection<PartyNomineeData> nominees;
}
@@ -1,59 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.data;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDate;
import lombok.Getter;
@Getter
public final class PartyNomineeData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final LocalDate nominationDate;
private final String name;
private final String mobileNo;
private final LocalDate birthDate;
private final Boolean minor;
private final String relation;
private final String gender;
private final String guardianName;
public static PartyNomineeData instance(final Long id, final LocalDate nominationDate, final String name, final String mobileNo,
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName) {
return new PartyNomineeData(id, nominationDate, name, mobileNo, birthDate, minor, relation, gender, guardianName);
}
private PartyNomineeData(final Long id, final LocalDate nominationDate, final String name, final String mobileNo,
final LocalDate birthDate, final Boolean minor, final String relation, final String gender, final String guardianName) {
this.id = id;
this.nominationDate = nominationDate;
this.name = name;
this.mobileNo = mobileNo;
this.birthDate = birthDate;
this.minor = minor;
this.relation = relation;
this.gender = gender;
this.guardianName = guardianName;
}
}
@@ -1,569 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.domain;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.portfolio.client.domain.Client;
/**
* A DDS Party - the customer's daily-deposit scheme account, linked to the Agent who collects for it.
* Carries the scheme's KYC, terms and two-stage overdue flags, plus a nominee list.
*
* The underlying RD (recurring deposit) account this scheme conceptually reuses is not wired up yet -
* {@code savingsAccountId} is a nullable placeholder for that future linkage (see the DDS architecture
* decision to reuse the RD engine for the deposit half). Until then the scheme/balance fields below
* are this record's own, not mirrored from any m_savings_account row.
*/
@Getter
@Entity
@Table(name = "m_dds_party", uniqueConstraints = { @UniqueConstraint(columnNames = { "party_code" }, name = "party_code_UNIQUE") })
public class Party extends AbstractPersistableCustom<Long> {
/** 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");
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_CLOSED = "CLOSED";
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@ManyToOne
@JoinColumn(name = "agent_id", nullable = false)
private Agent agent;
@Column(name = "savings_account_id")
private Long savingsAccountId;
@Column(name = "sub_type", length = 60, nullable = false)
private String subType;
@Column(name = "party_code", length = 20, nullable = false)
private String partyCode;
@Column(name = "last_account", length = 50)
private String lastAccount;
@Column(name = "nominal", nullable = false)
private boolean nominal;
@Column(name = "aadhar_no", length = 20)
private String aadharNo;
@Column(name = "party_name", length = 100, nullable = false)
private String partyName;
@Column(name = "party_name1", length = 100)
private String partyName1;
@Column(name = "address1", length = 200, nullable = false)
private String address1;
@Column(name = "address2", length = 200)
private String address2;
@Column(name = "address3", length = 200)
private String address3;
@Column(name = "city", length = 100, nullable = false)
private String city;
@Column(name = "pincode", length = 20, nullable = false)
private String pincode;
@Column(name = "gender", length = 20)
private String gender;
@Column(name = "pan_card_no", length = 20)
private String panCardNo;
@Column(name = "telephone", length = 20)
private String telephone;
@Column(name = "mobile_no_sms", length = 20, nullable = false)
private String mobileNoSms;
@Column(name = "operation", length = 20, nullable = false)
private String operation;
@Column(name = "birth_date")
private LocalDate birthDate;
@Column(name = "occupation", length = 40)
private String occupation;
@Column(name = "risk", length = 20)
private String risk;
@Column(name = "ckyc_id", length = 40)
private String ckycId;
@Column(name = "adult_minor", length = 20, nullable = false)
private String adultMinor;
@Column(name = "minor_date")
private LocalDate minorDate;
@Column(name = "guardian_client_id")
private Long guardianClientId;
@Column(name = "min_account_opt", length = 40)
private String minAccountOpt;
@Column(name = "relation", length = 40)
private String relation;
@Column(name = "opening_date", nullable = false)
private LocalDate openingDate;
@Column(name = "months")
private Integer month;
@Column(name = "maturity_date")
private LocalDate maturityDate;
@Column(name = "reopen_date")
private LocalDate reopenDate;
@Column(name = "deposit_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal depositAmount;
@Column(name = "daily_deposit_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal dailyDepositAmount;
@Column(name = "days", nullable = false)
private Integer days;
@Column(name = "ledger_no", length = 40)
private String ledgerNo;
@Column(name = "maturity_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal maturityAmount;
@Column(name = "interest_rate", scale = 6, precision = 19, nullable = false)
private BigDecimal interestRate;
@Column(name = "withdraw_type", length = 20)
private String withdrawType;
@Column(name = "od1_flag", nullable = false)
private boolean od1Flag;
@Column(name = "od2_flag", nullable = false)
private boolean od2Flag;
@Column(name = "lien", nullable = false)
private boolean lien;
@Column(name = "pay_date")
private LocalDate payDate;
@Column(name = "payment_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal paymentAmount;
@Column(name = "current_balance", scale = 6, precision = 19, nullable = false)
private BigDecimal currentBalance;
@Column(name = "opening_balance", scale = 6, precision = 19, nullable = false)
private BigDecimal openingBalance;
@Column(name = "is_sms_active", nullable = false)
private boolean isSmsActive;
@Column(name = "is_email_active", nullable = false)
private boolean isEmailActive;
@Column(name = "interest_calc", scale = 6, precision = 19, nullable = false)
private BigDecimal interestCalc;
@Column(name = "interest_date")
private LocalDate interestDate;
@Column(name = "sp_description", length = 500)
private String spDescription;
@Column(name = "freeze_code", length = 20, nullable = false)
private String freezeCode;
@Column(name = "adv_code", length = 40, nullable = false)
private String advCode;
@Column(name = "credit_to_branch", length = 100)
private String creditToBranch;
@Column(name = "credit_to_account_type", length = 100)
private String creditToAccountType;
@Column(name = "credit_to_account", length = 50)
private String creditToAccount;
@Column(name = "nominee_applicable", nullable = false)
private boolean nomineeApplicable;
@Column(name = "nominee_split_mode", length = 20)
private String nomineeSplitMode;
@Column(name = "status", length = 20, nullable = false)
private String status;
@Column(name = "close_date")
private LocalDate closeDate;
@OneToMany(mappedBy = "party", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("id")
private List<PartyNominee> nominees = new ArrayList<>();
protected Party() {
//
}
private Party(final Client client, final Agent agent, final String partyCode, final JsonCommand command) {
this.client = client;
this.agent = agent;
this.partyCode = partyCode;
this.subType = command.stringValueOfParameterNamed("subType");
this.lastAccount = command.stringValueOfParameterNamedAllowingNull("lastAccount");
this.nominal = command.booleanPrimitiveValueOfParameterNamed("nominal");
this.aadharNo = command.stringValueOfParameterNamedAllowingNull("aadharNo");
this.partyName = command.stringValueOfParameterNamed("partyName");
this.partyName1 = command.stringValueOfParameterNamedAllowingNull("partyName1");
this.address1 = command.stringValueOfParameterNamed("address1");
this.address2 = command.stringValueOfParameterNamedAllowingNull("address2");
this.address3 = command.stringValueOfParameterNamedAllowingNull("address3");
this.city = command.stringValueOfParameterNamed("city");
this.pincode = command.stringValueOfParameterNamed("pincode");
this.gender = command.stringValueOfParameterNamedAllowingNull("gender");
this.panCardNo = command.stringValueOfParameterNamedAllowingNull("panCardNo");
this.telephone = command.stringValueOfParameterNamedAllowingNull("telephone");
this.mobileNoSms = command.stringValueOfParameterNamed("mobileNoSms");
this.operation = command.stringValueOfParameterNamed("operation");
this.birthDate = command.localDateValueOfParameterNamed("birthDate");
this.occupation = command.stringValueOfParameterNamedAllowingNull("occupation");
this.risk = command.stringValueOfParameterNamedAllowingNull("risk");
this.ckycId = command.stringValueOfParameterNamedAllowingNull("ckycId");
this.adultMinor = command.stringValueOfParameterNamed("adultMinor");
this.minorDate = command.localDateValueOfParameterNamed("minorDate");
this.guardianClientId = command.longValueOfParameterNamed("guardianClientId");
this.minAccountOpt = command.stringValueOfParameterNamedAllowingNull("minAccountOpt");
this.relation = command.stringValueOfParameterNamedAllowingNull("relation");
this.openingDate = command.localDateValueOfParameterNamed("openingDate");
this.month = command.integerValueOfParameterNamedDefaultToNullIfZero("month");
this.maturityDate = command.localDateValueOfParameterNamed("maturityDate");
this.reopenDate = command.localDateValueOfParameterNamed("reopenDate");
this.depositAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("depositAmount"));
this.dailyDepositAmount = command.bigDecimalValueOfParameterNamed("dailyDepositAmount");
this.days = command.integerValueOfParameterNamed("days");
this.ledgerNo = command.stringValueOfParameterNamedAllowingNull("ledgerNo");
this.maturityAmount = BigDecimal.ZERO;
this.interestRate = command.parameterExists("interestRate")
? command.bigDecimalValueOfParameterNamed("interestRate")
: DEFAULT_INTEREST_RATE;
this.withdrawType = command.stringValueOfParameterNamedAllowingNull("withdrawType");
this.od1Flag = false;
this.od2Flag = false;
this.lien = command.booleanPrimitiveValueOfParameterNamed("lien");
this.payDate = command.localDateValueOfParameterNamed("payDate");
this.paymentAmount = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("paymentAmount"));
this.openingBalance = orZero(command.bigDecimalValueOfParameterNamedDefaultToNullIfZero("openingBalance"));
this.currentBalance = this.openingBalance;
this.isSmsActive = command.booleanPrimitiveValueOfParameterNamed("isSmsActive");
this.isEmailActive = command.booleanPrimitiveValueOfParameterNamed("isEmailActive");
this.interestCalc = BigDecimal.ZERO;
this.interestDate = command.localDateValueOfParameterNamed("interestDate");
this.spDescription = command.stringValueOfParameterNamedAllowingNull("spDescription");
this.freezeCode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("freezeCode"), "Normal");
this.advCode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("advCode"), "None");
this.creditToBranch = command.stringValueOfParameterNamedAllowingNull("creditToBranch");
this.creditToAccountType = command.stringValueOfParameterNamedAllowingNull("creditToAccountType");
this.creditToAccount = command.stringValueOfParameterNamedAllowingNull("creditToAccount");
this.nomineeApplicable = command.booleanPrimitiveValueOfParameterNamed("nomineeApplicable");
this.nomineeSplitMode = StringUtils.defaultIfBlank(command.stringValueOfParameterNamedAllowingNull("nomineeSplitMode"),
"Percentage");
this.status = STATUS_ACTIVE;
}
public static Party fromJson(final Client client, final Agent agent, final String partyCode, final JsonCommand command) {
return new Party(client, agent, partyCode, command);
}
private static BigDecimal orZero(final BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
public void addNominee(final PartyNominee nominee) {
nominee.setParty(this);
this.nominees.add(nominee);
}
public void resetNominees() {
this.nominees.clear();
}
public void changeClient(final Client newClient) {
this.client = newClient;
}
public void changeAgent(final Agent newAgent) {
this.agent = newAgent;
}
/**
* Links this Party to an existing real Savings Account, resolved by account number in the write
* service. There is no unlink - once a Party's DDS scheme is tied to a real account, it stays
* tied.
*/
public void linkSavingsAccount(final Long savingsAccountId) {
this.savingsAccountId = savingsAccountId;
}
public BigDecimal credit(final BigDecimal amount) {
this.currentBalance = this.currentBalance.add(amount);
return this.currentBalance;
}
public BigDecimal debit(final BigDecimal amount) {
this.currentBalance = this.currentBalance.subtract(amount);
return this.currentBalance;
}
public boolean isClosed() {
return STATUS_CLOSED.equals(this.status);
}
/**
* Closes this Party's account as of the given date. The balance must already be zero - a Party
* 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) {
this.status = STATUS_CLOSED;
this.closeDate = closeDate;
}
/**
* Stamps the date interest (regular or OD) was last calculated up to, so the next batch run
* accrues only the days since. Set on POST, not on calculate/preview.
*/
public void markInterestCalculatedThrough(final LocalDate date) {
this.interestDate = date;
}
public Long clientId() {
return this.client.getId();
}
public Long agentId() {
return this.agent.getId();
}
public Map<String, Object> update(final JsonCommand command) {
final Map<String, Object> actualChanges = new LinkedHashMap<>(40);
if (command.isChangeInLongParameterNamed("clientId", this.client.getId())) {
actualChanges.put("clientId", command.longValueOfParameterNamed("clientId"));
}
if (command.isChangeInLongParameterNamed("agentId", this.agent.getId())) {
actualChanges.put("agentId", command.longValueOfParameterNamed("agentId"));
}
stringChange(command, actualChanges, "subType", this.subType, v -> this.subType = v);
stringChange(command, actualChanges, "lastAccount", this.lastAccount, v -> this.lastAccount = v);
if (command.isChangeInBooleanParameterNamed("nominal", this.nominal)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("nominal");
actualChanges.put("nominal", newValue);
this.nominal = newValue;
}
stringChange(command, actualChanges, "aadharNo", this.aadharNo, v -> this.aadharNo = v);
stringChange(command, actualChanges, "partyName", this.partyName, v -> this.partyName = v);
stringChange(command, actualChanges, "partyName1", this.partyName1, v -> this.partyName1 = v);
stringChange(command, actualChanges, "address1", this.address1, v -> this.address1 = v);
stringChange(command, actualChanges, "address2", this.address2, v -> this.address2 = v);
stringChange(command, actualChanges, "address3", this.address3, v -> this.address3 = v);
stringChange(command, actualChanges, "city", this.city, v -> this.city = v);
stringChange(command, actualChanges, "pincode", this.pincode, v -> this.pincode = v);
stringChange(command, actualChanges, "gender", this.gender, v -> this.gender = v);
stringChange(command, actualChanges, "panCardNo", this.panCardNo, v -> this.panCardNo = v);
stringChange(command, actualChanges, "telephone", this.telephone, v -> this.telephone = v);
stringChange(command, actualChanges, "mobileNoSms", this.mobileNoSms, v -> this.mobileNoSms = v);
stringChange(command, actualChanges, "operation", this.operation, v -> this.operation = v);
if (command.isChangeInDateParameterNamed("birthDate", this.birthDate)) {
actualChanges.put("birthDate", command.stringValueOfParameterNamed("birthDate"));
this.birthDate = command.localDateValueOfParameterNamed("birthDate");
}
stringChange(command, actualChanges, "occupation", this.occupation, v -> this.occupation = v);
stringChange(command, actualChanges, "risk", this.risk, v -> this.risk = v);
stringChange(command, actualChanges, "ckycId", this.ckycId, v -> this.ckycId = v);
stringChange(command, actualChanges, "adultMinor", this.adultMinor, v -> this.adultMinor = v);
if (command.isChangeInDateParameterNamed("minorDate", this.minorDate)) {
actualChanges.put("minorDate", command.stringValueOfParameterNamed("minorDate"));
this.minorDate = command.localDateValueOfParameterNamed("minorDate");
}
if (command.isChangeInLongParameterNamed("guardianClientId", this.guardianClientId)) {
final Long newValue = command.longValueOfParameterNamed("guardianClientId");
actualChanges.put("guardianClientId", newValue);
this.guardianClientId = newValue;
}
stringChange(command, actualChanges, "minAccountOpt", this.minAccountOpt, v -> this.minAccountOpt = v);
stringChange(command, actualChanges, "relation", this.relation, v -> this.relation = v);
if (command.isChangeInDateParameterNamed("openingDate", this.openingDate)) {
actualChanges.put("openingDate", command.stringValueOfParameterNamed("openingDate"));
this.openingDate = command.localDateValueOfParameterNamed("openingDate");
}
if (command.isChangeInIntegerParameterNamed("month", this.month)) {
final Integer newValue = command.integerValueOfParameterNamed("month");
actualChanges.put("month", newValue);
this.month = newValue;
}
if (command.isChangeInDateParameterNamed("maturityDate", this.maturityDate)) {
actualChanges.put("maturityDate", command.stringValueOfParameterNamed("maturityDate"));
this.maturityDate = command.localDateValueOfParameterNamed("maturityDate");
}
if (command.isChangeInDateParameterNamed("reopenDate", this.reopenDate)) {
actualChanges.put("reopenDate", command.stringValueOfParameterNamed("reopenDate"));
this.reopenDate = command.localDateValueOfParameterNamed("reopenDate");
}
if (command.isChangeInBigDecimalParameterNamed("depositAmount", this.depositAmount)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("depositAmount");
actualChanges.put("depositAmount", newValue);
this.depositAmount = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("dailyDepositAmount", this.dailyDepositAmount)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("dailyDepositAmount");
actualChanges.put("dailyDepositAmount", newValue);
this.dailyDepositAmount = newValue;
}
if (command.isChangeInIntegerParameterNamed("days", this.days)) {
final Integer newValue = command.integerValueOfParameterNamed("days");
actualChanges.put("days", newValue);
this.days = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("interestRate", this.interestRate)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("interestRate");
actualChanges.put("interestRate", newValue);
this.interestRate = newValue;
}
stringChange(command, actualChanges, "ledgerNo", this.ledgerNo, v -> this.ledgerNo = v);
stringChange(command, actualChanges, "withdrawType", this.withdrawType, v -> this.withdrawType = v);
if (command.isChangeInBooleanParameterNamed("lien", this.lien)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("lien");
actualChanges.put("lien", newValue);
this.lien = newValue;
}
if (command.isChangeInDateParameterNamed("payDate", this.payDate)) {
actualChanges.put("payDate", command.stringValueOfParameterNamed("payDate"));
this.payDate = command.localDateValueOfParameterNamed("payDate");
}
if (command.isChangeInBigDecimalParameterNamed("paymentAmount", this.paymentAmount)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("paymentAmount");
actualChanges.put("paymentAmount", newValue);
this.paymentAmount = newValue;
}
if (command.isChangeInBigDecimalParameterNamed("openingBalance", this.openingBalance)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed("openingBalance");
actualChanges.put("openingBalance", newValue);
this.openingBalance = newValue;
}
if (command.isChangeInBooleanParameterNamed("isSmsActive", this.isSmsActive)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("isSmsActive");
actualChanges.put("isSmsActive", newValue);
this.isSmsActive = newValue;
}
if (command.isChangeInBooleanParameterNamed("isEmailActive", this.isEmailActive)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("isEmailActive");
actualChanges.put("isEmailActive", newValue);
this.isEmailActive = newValue;
}
if (command.isChangeInDateParameterNamed("interestDate", this.interestDate)) {
actualChanges.put("interestDate", command.stringValueOfParameterNamed("interestDate"));
this.interestDate = command.localDateValueOfParameterNamed("interestDate");
}
stringChange(command, actualChanges, "spDescription", this.spDescription, v -> this.spDescription = v);
stringChange(command, actualChanges, "freezeCode", this.freezeCode, v -> this.freezeCode = v);
stringChange(command, actualChanges, "advCode", this.advCode, v -> this.advCode = v);
stringChange(command, actualChanges, "creditToBranch", this.creditToBranch, v -> this.creditToBranch = v);
stringChange(command, actualChanges, "creditToAccountType", this.creditToAccountType, v -> this.creditToAccountType = v);
stringChange(command, actualChanges, "creditToAccount", this.creditToAccount, v -> this.creditToAccount = v);
if (command.isChangeInBooleanParameterNamed("nomineeApplicable", this.nomineeApplicable)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("nomineeApplicable");
actualChanges.put("nomineeApplicable", newValue);
this.nomineeApplicable = newValue;
}
stringChange(command, actualChanges, "nomineeSplitMode", this.nomineeSplitMode, v -> this.nomineeSplitMode = v);
return actualChanges;
}
private void stringChange(final JsonCommand command, final Map<String, Object> actualChanges, final String paramName,
final String existingValue, final Consumer<String> setter) {
if (command.isChangeInStringParameterNamed(paramName, existingValue)) {
final String newValue = command.stringValueOfParameterNamed(paramName);
actualChanges.put(paramName, newValue);
setter.accept(newValue);
}
}
}
@@ -1,88 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* A single nominee recorded against a {@link Party}. Maker-checker on nominee edits is enforced at
* the command layer (the whole Party update goes through the standard maker-checker command flow),
* not on this child row individually.
*/
@Getter
@Entity
@Table(name = "m_dds_party_nominee")
public class PartyNominee extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "party_id", nullable = false)
private Party party;
@Column(name = "nomination_date", nullable = false)
private LocalDate nominationDate;
@Column(name = "name", length = 100, nullable = false)
private String name;
@Column(name = "mobile_no", length = 20)
private String mobileNo;
@Column(name = "birth_date")
private LocalDate birthDate;
@Column(name = "minor", nullable = false)
private boolean minor;
@Column(name = "relation", length = 40, nullable = false)
private String relation;
@Column(name = "gender", length = 20, nullable = false)
private String gender;
@Column(name = "guardian_name", length = 100)
private String guardianName;
protected PartyNominee() {
//
}
public PartyNominee(final LocalDate nominationDate, final String name, final String mobileNo, final LocalDate birthDate,
final boolean minor, final String relation, final String gender, final String guardianName) {
this.nominationDate = nominationDate;
this.name = name;
this.mobileNo = StringUtils.defaultIfEmpty(mobileNo, null);
this.birthDate = birthDate;
this.minor = minor;
this.relation = relation;
this.gender = gender;
this.guardianName = StringUtils.defaultIfEmpty(guardianName, null);
}
void setParty(final Party party) {
this.party = party;
}
}
@@ -1,32 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when a Close Party command, or a Party Tran collection, targets a Party that is already
* closed.
*/
public class PartyAlreadyClosedException extends AbstractPlatformDomainRuleException {
public PartyAlreadyClosedException(final String partyCode) {
super("error.msg.party.already.closed", "Party " + partyCode + " is already closed", partyCode);
}
}
@@ -1,32 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when a Close Party command is attempted while the Party's balance is still non-zero - the
* remaining balance must be paid out (a Withdraw Deposite to zero) before the account can be closed.
*/
public class PartyHasBalanceException extends AbstractPlatformDomainRuleException {
public PartyHasBalanceException(final String partyCode) {
super("error.msg.party.has.balance", "Party " + partyCode + " still has a non-zero balance and cannot be closed", partyCode);
}
}
@@ -1,40 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
/**
* A {@link RuntimeException} thrown when a DDS party resource is not found.
*/
public class PartyNotFoundException extends AbstractPlatformResourceNotFoundException {
public PartyNotFoundException(final Long id) {
super("error.msg.party.id.invalid", "Party with identifier " + id + " does not exist", id);
}
public PartyNotFoundException(final Long id, final EmptyResultDataAccessException e) {
super("error.msg.party.id.invalid", "Party with identifier " + id + " does not exist", id, e);
}
public PartyNotFoundException(final String partyCode) {
super("error.msg.party.code.invalid", "Party with code " + partyCode + " does not exist", partyCode);
}
}
@@ -1,33 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when the savingsAccountNo given for a Party belongs to a different client than the Party's
* own clientId - a Party can only be linked to its own client's savings account.
*/
public class PartySavingsAccountClientMismatchException extends AbstractPlatformDomainRuleException {
public PartySavingsAccountClientMismatchException(final String savingsAccountNo) {
super("error.msg.party.savings.account.client.mismatch",
"Savings account " + savingsAccountNo + " does not belong to this party's client", savingsAccountNo);
}
}
@@ -1,32 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
/**
* Thrown when a Party's savingsAccountNo does not match any non-closed Savings Account.
*/
public class PartySavingsAccountNotFoundException extends AbstractPlatformResourceNotFoundException {
public PartySavingsAccountNotFoundException(final String savingsAccountNo) {
super("error.msg.party.savings.account.not.found", "No open savings account found with account number " + savingsAccountNo,
savingsAccountNo);
}
}
@@ -1,45 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.report.data;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import lombok.Builder;
import lombok.Getter;
/**
* A generic tabular result shape shared by every DDS report - one column list plus one row list, both
* keyed the same way, so a single report viewer screen can render any of them without a bespoke
* result type per report.
*/
@Getter
@Builder
public final class DdsReportData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final String reportKey;
private final boolean available;
private final String message;
private final List<String> columns;
private final List<Map<String, Object>> rows;
}
@@ -1,52 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public final class PartyTransactionData implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final Long id;
private final Long partyId;
private final String partyCode;
private final String partyName;
private final Long agentId;
private final String agentCode;
private final LocalDate transactionDate;
private final String transactionType;
private final String direction;
private final String transactionMode;
private final String accountType;
private final String accountNo;
private final BigDecimal amount;
private final String chequeNo;
private final String tokenNo;
private final String description;
private final BigDecimal balanceAfterTransaction;
}
@@ -1,110 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.portfolio.dds.party.domain.Party;
/**
* A single posted entry on the DDS ledger: a Party's day's collection ("Party Tran", direction
* CREDIT), one leg of a "Withdraw Deposite" transfer between two parties (the creditor leg CREDIT,
* the debtor leg DEBIT), or an Agent's settlement of collected cash to its branch ("Agent Tran",
* direction DEBIT - no {@link Party} involved, so {@code party} is null for this type).
* {@code balanceAfterTransaction} holds the Party's balance for Party-linked rows, or the Agent's
* balance for an Agent Tran row. Immutable once posted - like any transaction, it is reversed with
* an opposing entry, not edited, so there is deliberately no update API for this entity.
*/
@Getter
@Entity
@Table(name = "m_dds_party_transaction")
public class PartyTransaction extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "party_id")
private Party party;
@ManyToOne
@JoinColumn(name = "agent_id", nullable = false)
private Agent agent;
@Column(name = "transaction_date", nullable = false)
private LocalDate transactionDate;
@Column(name = "transaction_type", length = 30, nullable = false)
private String transactionType;
@Column(name = "direction", length = 10, nullable = false)
private String direction;
@Column(name = "transaction_mode", length = 20)
private String transactionMode;
@Column(name = "account_type", length = 100)
private String accountType;
@Column(name = "account_no", length = 50)
private String accountNo;
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
private BigDecimal amount;
@Column(name = "cheque_no", length = 40)
private String chequeNo;
@Column(name = "token_no", length = 40)
private String tokenNo;
@Column(name = "description", length = 500)
private String description;
@Column(name = "balance_after_transaction", scale = 6, precision = 19, nullable = false)
private BigDecimal balanceAfterTransaction;
protected PartyTransaction() {
//
}
public PartyTransaction(final Party party, final Agent agent, final LocalDate transactionDate, final String transactionType,
final String direction, final String transactionMode, final String accountType, final String accountNo,
final BigDecimal amount, final String chequeNo, final String tokenNo, final String description,
final BigDecimal balanceAfterTransaction) {
this.party = party;
this.agent = agent;
this.transactionDate = transactionDate;
this.transactionType = transactionType;
this.direction = direction;
this.transactionMode = transactionMode;
this.accountType = accountType;
this.accountNo = accountNo;
this.amount = amount;
this.chequeNo = chequeNo;
this.tokenNo = tokenNo;
this.description = description;
this.balanceAfterTransaction = balanceAfterTransaction;
}
}
@@ -1,33 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when an Agent Tran settlement amount exceeds the Agent's current (collected but not yet
* settled) balance.
*/
public class InsufficientAgentBalanceException extends AbstractPlatformDomainRuleException {
public InsufficientAgentBalanceException(final String agentCode) {
super("error.msg.agent.insufficient.balance",
"Agent " + agentCode + " does not have sufficient collected balance for this settlement", agentCode);
}
}
@@ -1,32 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when a Withdraw Deposite transfer would take the debtor Party's balance below zero.
*/
public class InsufficientPartyBalanceException extends AbstractPlatformDomainRuleException {
public InsufficientPartyBalanceException(final String partyCode) {
super("error.msg.party.insufficient.balance", "Party " + partyCode + " does not have sufficient balance for this transfer",
partyCode);
}
}
@@ -1,32 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
/**
* A {@link RuntimeException} thrown when a DDS party transaction resource is not found.
*/
public class PartyTransactionNotFoundException extends AbstractPlatformResourceNotFoundException {
public PartyTransactionNotFoundException(final Long id, final EmptyResultDataAccessException e) {
super("error.msg.partytransaction.id.invalid", "Party transaction with identifier " + id + " does not exist", id, e);
}
}
@@ -31,7 +31,8 @@ public enum DepositAccountOnClosureType {
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"); //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
private final Integer value;
private final String code;
@@ -51,6 +52,7 @@ public enum DepositAccountOnClosureType {
case 200 -> TRANSFER_TO_SAVINGS;
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
case 400 -> REINVEST_PRINCIPAL_ONLY;
case 500 -> PARTIAL_REINVEST;
default -> INVALID;
};
}
@@ -85,6 +87,10 @@ public enum DepositAccountOnClosureType {
return this.equals(INVALID);
}
public boolean isPartialReinvest() {
return this.equals(PARTIAL_REINVEST);
}
// TODO: do we really need this?!?
public static Object[] integerValues() {
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
@@ -208,6 +208,7 @@ public final class DepositsApiConstants {
public static final String onAccountClosureIdParamName = "onAccountClosureId";
public static final String transferDescriptionParamName = "transferDescription";
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
public static final String reinvestAmountParamName = "reinvestAmount";
public static final String savingsAccounts = "savingsAccounts";
public static final String maturityInstructionIdParamName = "maturityInstructionId";
public static final String transferToSavingsIdParamName = "transferToSavingsId";
@@ -275,7 +275,6 @@ public class SavingsApiConstants {
// Additional custom fields
public static final String balanceFrequencyParamName = "balanceFrequency";
public static final String accountClosedZeroBalanceInDayClosingParamName = "accountClosedZeroBalanceInDayClosing";
public static final String productCategoryParamName = "productCategory";
// Current Product — new UI fields
public static final String balanceSwapOptionParamName = "balanceSwapOption";
@@ -144,7 +144,6 @@ public final class SavingsProductData implements Serializable {
@Getter @Setter private Boolean enableLockinPeriod;
@Getter @Setter private String balanceFrequency;
@Getter @Setter private Boolean accountClosedZeroBalanceInDayClosing;
@Getter @Setter private String productCategory;
// New current product fields
@Getter @Setter private Boolean balanceSwapOption;
@@ -797,6 +797,10 @@ public final class SavingsEnumerations {
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
break;
case PARTIAL_REINVEST:
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
break;
}
return optionData;
}
@@ -0,0 +1,185 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.dividendpaidreport.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Every declared dividend, financial year by financial year: how much was declared, how much members have collected,
* how much is still unclaimed, and how much has come back to profit.
*
* The GL accounts are shared across years, so their balances only ever give running totals — "how much of FY 2026-27 is
* still unclaimed" cannot be read off Dividend Payable. This walks the declarations themselves instead: locks carry the
* per-member entitlement and their status says what became of it, which is the authoritative record. The GL is where the
* money moved; the locks are what it moved for.
*
* The columns mirror the money's actual path, which begins and ends in the same place:
*
* <pre>
* declare Dr Bank Profit Cr Dividend Payable
* member claims Dr Dividend Payable Cr Dividend Paid → Cr Sponsor DD Payable
* 3 yrs unclaimed Dr Dividend Payable Cr Bank Profit
* </pre>
*
* So for a closed year, claimed + forfeited + rounding residue should account for the whole declaration; anything left
* is what is still sitting unclaimed and still within its three-year window.
*/
@Path("/v1/accounting/dividend-yearwise-report")
@Component
@Tag(name = "Dividend Year-wise Report", description = "Declared, claimed, unclaimed and forfeited dividend per financial year.")
@RequiredArgsConstructor
public class DividendYearwiseReportApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DIVIDEND_SHAREPRODUCT";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String report() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
// A declaration is labelled by the financial year its period CLOSES in, which is how a member
// and an auditor both refer to it: a period ending 31-Mar-2027 is FY 2026-27. Locks are joined
// through the per-member detail rather than counted off the declaration, because a lock is only
// created for a member who actually had something to collect.
// Declared amounts and lock outcomes are aggregated separately before being joined by year.
// Summing the declaration amount across a join to its locks would multiply it by the number
// of members it was split between.
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
WITH fy AS (
SELECT d.id,
CASE WHEN EXTRACT(MONTH FROM d.dividend_period_end_date) >= 4
THEN EXTRACT(YEAR FROM d.dividend_period_end_date)
ELSE EXTRACT(YEAR FROM d.dividend_period_end_date) - 1 END AS fy_start,
d.amount
FROM m_share_product_dividend_pay_out d
),
declared AS (
SELECT fy_start, COUNT(*) AS declaration_count, SUM(amount) AS declared_amount
FROM fy GROUP BY fy_start
),
outcomes AS (
SELECT fy.fy_start,
COALESCE(SUM(CASE WHEN l.status = 'CLAIMED' THEN l.amount ELSE 0 END), 0) AS claimed_amount,
COUNT(l.id) FILTER (WHERE l.status = 'CLAIMED') AS claimed_count,
COALESCE(SUM(CASE WHEN l.status = 'UNCLAIMED' THEN l.amount ELSE 0 END), 0) AS unclaimed_amount,
COUNT(l.id) FILTER (WHERE l.status = 'UNCLAIMED') AS unclaimed_count,
COALESCE(SUM(CASE WHEN l.status = 'FORFEITED' THEN l.amount ELSE 0 END), 0) AS forfeited_amount,
COUNT(l.id) FILTER (WHERE l.status = 'FORFEITED') AS forfeited_count,
MIN(l.release_due_date) FILTER (WHERE l.status = 'UNCLAIMED') AS earliest_release_due
FROM fy
LEFT JOIN m_share_account_dividend_details sad ON sad.dividend_pay_out_id = fy.id
LEFT JOIN m_share_dividend_lock l ON l.dividend_detail_id = sad.id
GROUP BY fy.fy_start
)
SELECT d.fy_start, d.declaration_count, d.declared_amount,
COALESCE(o.claimed_amount, 0) AS claimed_amount, COALESCE(o.claimed_count, 0) AS claimed_count,
COALESCE(o.unclaimed_amount, 0) AS unclaimed_amount, COALESCE(o.unclaimed_count, 0) AS unclaimed_count,
COALESCE(o.forfeited_amount, 0) AS forfeited_amount, COALESCE(o.forfeited_count, 0) AS forfeited_count,
o.earliest_release_due
FROM declared d
LEFT JOIN outcomes o ON o.fy_start = d.fy_start
ORDER BY d.fy_start DESC
""");
// Rounding residue is tracked per declaration rather than per lock — it never belonged to a
// member, so no lock was ever created for it. Pulled separately and matched by year.
final Map<String, BigDecimal> residueByYear = new LinkedHashMap<>();
for (final Map<String, Object> row : jdbcTemplate.queryForList("""
SELECT CASE WHEN EXTRACT(MONTH FROM period_end_date) >= 4
THEN EXTRACT(YEAR FROM period_end_date)
ELSE EXTRACT(YEAR FROM period_end_date) - 1 END AS fy_start,
COALESCE(SUM(residue_amount), 0) AS residue
FROM m_share_dividend_rounding_residue
GROUP BY fy_start
""")) {
residueByYear.put(financialYearLabel(((Number) row.get("fy_start")).intValue()), (BigDecimal) row.get("residue"));
}
final List<Map<String, Object>> years = new ArrayList<>();
BigDecimal totalDeclared = BigDecimal.ZERO;
BigDecimal totalClaimed = BigDecimal.ZERO;
BigDecimal totalUnclaimed = BigDecimal.ZERO;
BigDecimal totalForfeited = BigDecimal.ZERO;
for (final Map<String, Object> row : rows) {
final String financialYear = financialYearLabel(((Number) row.get("fy_start")).intValue());
final BigDecimal declared = (BigDecimal) row.get("declared_amount");
final BigDecimal claimed = (BigDecimal) row.get("claimed_amount");
final BigDecimal unclaimed = (BigDecimal) row.get("unclaimed_amount");
final BigDecimal forfeited = (BigDecimal) row.get("forfeited_amount");
final BigDecimal residue = residueByYear.getOrDefault(financialYear, BigDecimal.ZERO);
final Map<String, Object> entry = new LinkedHashMap<>();
entry.put("financialYear", financialYear);
entry.put("declarationCount", row.get("declaration_count"));
entry.put("declaredAmount", declared);
entry.put("claimedAmount", claimed);
entry.put("claimedCount", row.get("claimed_count"));
entry.put("unclaimedAmount", unclaimed);
entry.put("unclaimedCount", row.get("unclaimed_count"));
entry.put("forfeitedAmount", forfeited);
entry.put("forfeitedCount", row.get("forfeited_count"));
entry.put("roundingResidueAmount", residue);
// What has already gone back to profit for this year: forfeited entitlements plus the
// rounding residue swept at declaration time.
entry.put("returnedToProfitAmount", forfeited.add(residue));
// When this year's oldest unclaimed lock forfeits — the date the remaining unclaimed
// amount starts returning to profit.
entry.put("earliestReleaseDueDate",
row.get("earliest_release_due") == null ? null : String.valueOf(row.get("earliest_release_due")));
years.add(entry);
totalDeclared = totalDeclared.add(declared);
totalClaimed = totalClaimed.add(claimed);
totalUnclaimed = totalUnclaimed.add(unclaimed);
totalForfeited = totalForfeited.add(forfeited);
}
final Map<String, Object> response = new LinkedHashMap<>();
response.put("years", years);
response.put("totalDeclared", totalDeclared);
response.put("totalClaimed", totalClaimed);
response.put("totalUnclaimed", totalUnclaimed);
response.put("totalForfeited", totalForfeited);
return gson.toJson(response);
}
private String financialYearLabel(final int startYear) {
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
}
@@ -1263,6 +1263,11 @@ public class AccountingProcessorHelper {
accountMapping = paymentChannelSpecificAccountMapping;
}
}
if (accountMapping == null) {
throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId,
CashAccountsForSavings.fromInt(accountMappingTypeId).toString());
}
glAccount = accountMapping.getGlAccount();
}
return glAccount;
@@ -0,0 +1,278 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.profitandloss.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.Date;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* How much profit a share dividend may actually be declared out of, for a given period.
*
* A co-operative society may not distribute raw net profit: the Co-operative Societies Act requires a Statutory Reserve
* Fund transfer and a state-federation education-fund contribution to come out first, and dividends already declared
* for other share products in the same period consume what is left. This endpoint applies that whole waterfall in one
* place so the declaration screen and the declaration-time validation cannot disagree about the ceiling.
*
* Rates come from c_configuration (see changelog 0478) because they vary by state and by year. A disabled configuration
* row means the society has an exemption from that appropriation, which is treated as 0% rather than as an error.
*/
@Path("/v1/accounting/distributable-profit")
@Component
@Tag(name = "Distributable Profit", description = "Net profit reduced by statutory appropriations and dividends already declared, giving the ceiling a new share dividend may be declared within.")
@RequiredArgsConstructor
public class DistributableProfitApiResource {
/**
* The year's closed-out profit, which is what a dividend may be declared against; kept in step with
* IncomeProfitTransferService.PROFIT_GL_CODE.
*
* Deliberately not plain Bank Profit — that holds what earlier declarations left behind, and reading it here would
* offer profit already distributed once for distribution again.
*/
private static final String PROFIT_GL_CODE = "BANKPROFITND01";
private static final String CONFIG_STATUTORY_RESERVE = "dividend-statutory-reserve-percentage";
private static final String CONFIG_EDUCATION_FUND = "dividend-education-fund-percentage";
private static final String CONFIG_MAXIMUM_RATE = "dividend-maximum-rate-percentage";
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String getDistributableProfit(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("excludeProductId") final Long excludeProductId) {
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
}
final Date from;
final Date to;
try {
from = Date.valueOf(fromDate);
to = Date.valueOf(toDate);
} catch (final IllegalArgumentException e) {
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
}
if (to.before(from)) {
throw apiError(400, "toDate must not be before fromDate");
}
// A dividend is normally declared for a whole, closed financial year, and then the audited
// profit in Bank Profit is the figure to use. A period that is not a full financial year can
// only be an interim declaration, which necessarily runs against the still-open P&L.
final String financialYear = financialYearOf(from.toLocalDate());
final LocalDate[] fyRange = financialYearRange(financialYear);
final boolean coversWholeFinancialYear = !from.toLocalDate().isAfter(fyRange[0]) && !to.toLocalDate().isBefore(fyRange[1]);
final BigDecimal closedProfit = coversWholeFinancialYear ? closedProfitForFinancialYear(financialYear) : null;
final boolean yearClosed = closedProfit != null;
final BigDecimal netProfit = yearClosed ? closedProfit : netProfitForPeriod(from, to);
final BigDecimal statutoryReservePercentage = configuredPercentage(CONFIG_STATUTORY_RESERVE);
final BigDecimal educationFundPercentage = configuredPercentage(CONFIG_EDUCATION_FUND);
final BigDecimal maximumRatePercentage = configuredPercentage(CONFIG_MAXIMUM_RATE);
// Appropriations only ever reduce a profit. A loss-making period has nothing to appropriate
// and nothing to distribute, so the whole waterfall collapses to zero rather than producing
// a negative "reserve" that would read as money owed back to the society.
final boolean profitable = netProfit.signum() > 0;
final BigDecimal statutoryReserveAmount = profitable ? percentageOf(netProfit, statutoryReservePercentage) : BigDecimal.ZERO;
final BigDecimal educationFundAmount = profitable ? percentageOf(netProfit, educationFundPercentage) : BigDecimal.ZERO;
final BigDecimal distributableProfit = profitable ? netProfit.subtract(statutoryReserveAmount).subtract(educationFundAmount)
: BigDecimal.ZERO;
final List<Map<String, Object>> existingDeclarations = declarationsOverlapping(from, to, excludeProductId);
BigDecimal alreadyDeclared = BigDecimal.ZERO;
for (final Map<String, Object> declaration : existingDeclarations) {
alreadyDeclared = alreadyDeclared.add((BigDecimal) declaration.get("amount"));
}
// An over-declaration that already happened (or appropriation rates raised after the fact)
// must not present as negative headroom, which a caller could read as a credit.
final BigDecimal availableForDeclaration = distributableProfit.subtract(alreadyDeclared).max(BigDecimal.ZERO);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("fromDate", fromDate);
response.put("toDate", toDate);
response.put("financialYear", financialYear);
response.put("coversWholeFinancialYear", coversWholeFinancialYear);
response.put("yearClosed", yearClosed);
// Names the figure so the screen can say where it came from — the closed books, or a P&L
// that is still moving — rather than showing a bare number the officer has to interpret.
response.put("profitSource", yearClosed ? "BANK_PROFIT_GL" : "OPEN_PROFIT_AND_LOSS");
response.put("netProfit", netProfit);
response.put("statutoryReservePercentage", statutoryReservePercentage);
response.put("statutoryReserveAmount", statutoryReserveAmount);
response.put("educationFundPercentage", educationFundPercentage);
response.put("educationFundAmount", educationFundAmount);
response.put("distributableProfit", distributableProfit);
response.put("alreadyDeclared", alreadyDeclared);
response.put("availableForDeclaration", availableForDeclaration);
response.put("maximumRatePercentage", maximumRatePercentage);
response.put("existingDeclarations", existingDeclarations);
return gson.toJson(response);
}
/**
* The year's profit as the books actually record it, once the year has been closed.
*
* A dividend is declared out of the AUDITED profit of a completed financial year, and that figure lives in the
* Bank Profit equity GL after year-end closing moves every income and expense GL into it. Reading it here — rather
* than re-deriving income-less-expense — means the dividend can never disagree with the closed books: if a
* closing entry was adjusted, reversed, or posted late, the dividend ceiling moves with it.
*
* Returns null when the year has not been closed, which the caller reports as a blocking condition rather than
* silently falling back to the live P&L. Distributing against an unclosed year would be distributing a profit
* figure that is still moving.
*/
private BigDecimal closedProfitForFinancialYear(final String financialYear) {
final Integer closedCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?", Integer.class, financialYear);
if (closedCount == null || closedCount == 0) {
return null;
}
final LocalDate[] range = financialYearRange(financialYear);
final BigDecimal balance = jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0)
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE a.gl_code = ? AND je.reversed = false AND je.entry_date BETWEEN ? AND ?
""", BigDecimal.class, PROFIT_GL_CODE, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
return balance == null ? BigDecimal.ZERO : balance;
}
/** "2026-27" -> 1 April 2026 to 31 March 2027. */
private LocalDate[] financialYearRange(final String financialYear) {
try {
final int startYear = Integer.parseInt(financialYear.substring(0, 4));
return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
} catch (final RuntimeException e) {
throw apiError(400, "financialYear must look like 2026-27. Received: " + financialYear);
}
}
/** The Indian financial year a date falls in, as a "YYYY-YY" label. */
private String financialYearOf(final LocalDate date) {
final int startYear = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
/**
* Same income-less-expense computation as {@link ProfitAndLossApiResource}, over posted, non-reversed journal
* entries only. Used only for the interim case — a period inside a year that has not been closed yet.
*/
private BigDecimal netProfitForPeriod(final Date from, final Date to) {
final Map<String, Object> row = jdbcTemplate.queryForMap("""
SELECT
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
ELSE 0 END), 0) AS total_income,
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
ELSE 0 END), 0) AS total_expense
FROM acc_gl_journal_entry je
JOIN acc_gl_account a ON a.id = je.account_id
WHERE je.reversed = false
AND je.entry_date BETWEEN ? AND ?
""", from, to);
final BigDecimal totalIncome = (BigDecimal) row.get("total_income");
final BigDecimal totalExpense = (BigDecimal) row.get("total_expense");
return totalIncome.subtract(totalExpense);
}
/**
* Every dividend already declared against a period overlapping this one, across ALL share products — the
* appropriation waterfall is society-wide, so a second product's declaration competes for the same distributable
* profit as the first.
*
* excludeProductId lets the declaration screen leave out the product being declared for, whose own overlapping
* declaration is separately blocked outright.
*/
private List<Map<String, Object>> declarationsOverlapping(final Date from, final Date to, final Long excludeProductId) {
final StringBuilder sql = new StringBuilder("""
SELECT d.id AS declaration_id, d.product_id, p.name AS product_name, d.amount,
d.dividend_period_start_date, d.dividend_period_end_date, d.status
FROM m_share_product_dividend_pay_out d
JOIN m_share_product p ON p.id = d.product_id
WHERE d.dividend_period_start_date <= ? AND d.dividend_period_end_date >= ?
""");
final List<Object> params = new ArrayList<>(List.of(to, from));
if (excludeProductId != null) {
sql.append(" AND d.product_id <> ? ");
params.add(excludeProductId);
}
sql.append(" ORDER BY d.dividend_period_start_date, d.id");
final List<Map<String, Object>> declarations = new ArrayList<>();
for (final Map<String, Object> row : jdbcTemplate.queryForList(sql.toString(), params.toArray())) {
final Map<String, Object> declaration = new LinkedHashMap<>();
declaration.put("declarationId", row.get("declaration_id"));
declaration.put("productId", row.get("product_id"));
declaration.put("productName", row.get("product_name"));
declaration.put("amount", row.get("amount"));
declaration.put("periodStartDate", String.valueOf(row.get("dividend_period_start_date")));
declaration.put("periodEndDate", String.valueOf(row.get("dividend_period_end_date")));
declaration.put("status", row.get("status"));
declarations.add(declaration);
}
return declarations;
}
/**
* A missing or disabled configuration row means the society does not make that appropriation (or has no ceiling),
* which is 0 — not a failure. Only an enabled row with a value applies.
*/
private BigDecimal configuredPercentage(final String configurationName) {
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT value FROM c_configuration WHERE name = ? AND enabled = true", configurationName);
if (rows.isEmpty() || rows.get(0).get("value") == null) {
return BigDecimal.ZERO;
}
return new BigDecimal(String.valueOf(rows.get(0).get("value")));
}
private BigDecimal percentageOf(final BigDecimal amount, final BigDecimal percentage) {
return amount.multiply(percentage).divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP);
}
private WebApplicationException apiError(final int httpStatus, final String message) {
return new WebApplicationException(
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
}
}
@@ -0,0 +1,180 @@
package org.apache.fineract.accounting.yearend.api;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Financial year-end closing of the share entry fee into the Bank Profit equity GL.
*
* <p>
* {@code GET} reports how much is waiting to be posted for the year and what has been posted before; {@code POST}
* performs the closing. The scheduled job {@code Post Share Entry Fee To Profit} runs the same service on 31 March, so
* this endpoint is the manual equivalent — useful when the job did not run, or the accountant wants to close early.
*/
@Path("/v1/accounting/incomeprofittransfer")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class IncomeProfitTransferApiResource {
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final IncomeProfitTransferService service;
private final Gson gson = new Gson();
// ══════════════════════════════════════════════════════════════════════
// GET — how much is waiting to be posted, and what has been posted before
// ══════════════════════════════════════════════════════════════════════
@GET
public Response preview(@QueryParam("incomeGlCode") final String incomeGlCodeParam,
@QueryParam("financialYear") final String financialYearParam) {
this.context.authenticatedUser();
final String incomeGlCode = incomeGlCodeParam == null || incomeGlCodeParam.isBlank()
? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
: incomeGlCodeParam.trim();
final Map<String, Object> incomeGl = service.loadGlByCode(incomeGlCode);
final Map<String, Object> profitGl = service.loadGlByCode(IncomeProfitTransferService.PROFIT_GL_CODE);
final String financialYear = financialYearParam == null || financialYearParam.isBlank() ? service.currentFinancialYear()
: financialYearParam.trim();
final LocalDate[] range = service.financialYearRange(financialYear);
final Long incomeGlId = toLong(incomeGl.get("id"));
final BigDecimal pending = service.balanceOf(incomeGlId, range);
final int entryCount = service.entryCountOf(incomeGlId, range);
final List<Map<String, Object>> history = jdbcTemplate
.queryForList("SELECT t.id, t.financial_year, t.amount, t.entry_count, t.posted_on_date, t.posted_by_name, "
+ " t.gl_transaction_id, g.gl_code AS income_gl_code, g.name AS income_gl_name "
+ " FROM m_income_profit_transfer t JOIN acc_gl_account g ON g.id = t.income_gl_id "
+ " WHERE t.income_gl_id = ? ORDER BY t.financial_year DESC, t.id DESC", incomeGlId);
final boolean alreadyPosted = history.stream().anyMatch(h -> financialYear.equals(h.get("financial_year")));
final Map<String, Object> out = new LinkedHashMap<>();
out.put("financialYear", financialYear);
out.put("fromDate", range[0].toString());
out.put("toDate", range[1].toString());
out.put("incomeGlCode", incomeGl.get("gl_code"));
out.put("incomeGlName", incomeGl.get("name"));
out.put("profitGlCode", profitGl.get("gl_code"));
out.put("profitGlName", profitGl.get("name"));
out.put("pendingAmount", pending);
out.put("entryCount", entryCount);
out.put("alreadyPosted", alreadyPosted);
out.put("postedHistory", history);
out.put("totalPostedToProfit", history.stream().map(h -> (BigDecimal) h.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST — close the income GL into Bank Profit
// ══════════════════════════════════════════════════════════════════════
@POST
@Transactional
public Response post(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final String incomeGlCode = getString(json, "incomeGlCode") == null ? IncomeProfitTransferService.DEFAULT_INCOME_GL_CODE
: getString(json, "incomeGlCode").trim();
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
: getString(json, "financialYear").trim();
// strict = true: a manual posting reports "already closed" or "nothing to post" as an
// error, so the user is told why nothing happened.
final Map<String, Object> result = service.postToProfit(incomeGlCode, financialYear, currentUser.getId(), currentUser.getUsername(),
sanitiseNote(getString(json, "note")), true);
return Response.ok(gson.toJson(result)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST /close-year — close the WHOLE P&L into Bank Profit
// ══════════════════════════════════════════════════════════════════════
/**
* Closes every income and expense GL for the year, so Bank Profit ends up holding the year's real net profit.
*
* This is the full year-end close; the endpoint above closes one named income GL and remains for the narrower
* share-entry-fee case. The share dividend declaration reads Bank Profit as the year's profit, so it is this that
* has to have run — after a single-GL posting the rest of the profit is still sitting in the income accounts.
*/
@POST
@Path("/close-year")
@Transactional
public Response closeYear(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final String financialYear = getString(json, "financialYear") == null ? service.currentFinancialYear()
: getString(json, "financialYear").trim();
// strict = true: a manual close reports "nothing left to close" as an error, so the user is
// told why nothing happened rather than getting a silent success.
final Map<String, Object> result = service.closeAllToProfit(financialYear, currentUser.getId(), currentUser.getUsername(),
sanitiseNote(getString(json, "note")), true);
return Response.ok(gson.toJson(result)).build();
}
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
private JsonObject parseBody(final String body) {
if (body == null || body.isBlank()) {
return new JsonObject();
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (RuntimeException e) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.request.invalid", "Request body must be valid JSON.");
}
}
private String sanitiseNote(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
final String trimmed = raw.trim();
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
}
private String getString(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit;
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class PostShareEntryFeeToProfitConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private IncomeProfitTransferService incomeProfitTransferService;
@Bean
protected Step postShareEntryFeeToProfitStep() {
return new StepBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository)
.tasklet(postShareEntryFeeToProfitTasklet(), transactionManager).build();
}
@Bean
public Job postShareEntryFeeToProfitJob() {
return new JobBuilder(JobName.POST_SHARE_ENTRY_FEE_TO_PROFIT.name(), jobRepository).start(postShareEntryFeeToProfitStep())
.incrementer(new RunIdIncrementer()).build();
}
@Bean
public PostShareEntryFeeToProfitTasklet postShareEntryFeeToProfitTasklet() {
return new PostShareEntryFeeToProfitTasklet(incomeProfitTransferService);
}
}
@@ -0,0 +1,61 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.accounting.yearend.jobs.postshareentryfeetoprofit;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.yearend.service.IncomeProfitTransferService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
/**
* Year-end closing of the share entry fee: on 31 March the balance of Share Entry Fee Income is posted to the Bank
* Profit equity GL, leaving the income account at nil for the new year.
*
* <p>
* Runs unattended, so a no-op (already closed for the year, or no fee collected) is a normal outcome and is logged
* rather than failing the job. Only a genuine posting failure is raised.
*/
@Slf4j
@RequiredArgsConstructor
public class PostShareEntryFeeToProfitTasklet implements Tasklet {
/** Fineract's system user id, used because a scheduled run has no logged-in user. */
private static final long SYSTEM_USER_ID = 1L;
private final IncomeProfitTransferService incomeProfitTransferService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
final String financialYear = incomeProfitTransferService.currentFinancialYear();
// Closes the WHOLE P&L, not just the share entry fee it was originally written for. Bank Profit
// is now what the share dividend declaration reads as "the year's profit", and a partial close
// would leave most of the profit still sitting in the income GLs — understating what may be
// distributed. Per-GL guards inside closeAllToProfit make a re-run safe.
final Map<String, Object> result = incomeProfitTransferService.closeAllToProfit(financialYear, SYSTEM_USER_ID, "system",
"Automatic year-end closing", false);
log.info("Year-end closing for {}: {}", financialYear, result.get("message"));
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,377 @@
package org.apache.fineract.accounting.yearend.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Closes an income GL into the Bank Profit equity GL at financial year end.
*
* <pre>
* Dr Share Entry Fee Income (10.4) = balance for the year
* Cr Bank Profit (BANKPROFIT01)
* </pre>
*
* Debiting the income account brings it back to nil so the new year starts clean; the credit accumulates in equity as
* retained profit.
*
* <p>
* Shared by the scheduled year-end job and the manual posting endpoint, so both paths post identical entries and are
* protected by the same "once per year" guard. The caller supplies the acting user id because the job runs without a
* logged-in user.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class IncomeProfitTransferService {
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int DEBIT = 2;
private static final int CREDIT = 1;
/** Equity GL that accumulates profit. */
/**
* The year's profit is closed into the Non-Distributable account, not plain Bank Profit.
*
* A dividend is declared against this balance; whatever the members do not receive moves across to Bank Profit once
* the declaration is approved. Closing into Bank Profit directly would mix the year just ended with earlier years'
* leftovers, and the next declaration would be sized on profit that had already been through a distribution
* decision — offering the same money for distribution twice.
*/
public static final String PROFIT_GL_CODE = "BANKPROFITND01";
/** Income GL closed to profit by the year-end job — the share entry fee. */
public static final String DEFAULT_INCOME_GL_CODE = "10.4";
private final JdbcTemplate jdbcTemplate;
/**
* Posts the year-end transfer. Returns a summary of what was posted, or a summary with {@code posted = false} when
* there was nothing to do (already closed, or a nil balance) — those are normal outcomes for a scheduled run, not
* errors.
*
* @param strict
* when true (manual posting) a no-op raises an error so the user sees why; when false (scheduled job) it
* returns quietly.
*/
@Transactional
public Map<String, Object> postToProfit(final String incomeGlCode, final String financialYear, final Long userId, final String userName,
final String note, final boolean strict) {
final Map<String, Object> incomeGl = loadGlByCode(incomeGlCode);
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
final Long incomeGlId = toLong(incomeGl.get("id"));
final Long profitGlId = toLong(profitGl.get("id"));
final LocalDate[] range = financialYearRange(financialYear);
final Long officeId = headOfficeId();
final Map<String, Object> out = new LinkedHashMap<>();
out.put("financialYear", financialYear);
out.put("incomeGlCode", incomeGl.get("gl_code"));
out.put("incomeGlName", incomeGl.get("name"));
out.put("profitGlCode", profitGl.get("gl_code"));
out.put("profitGlName", profitGl.get("name"));
final Integer existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
Integer.class, financialYear, incomeGlId, officeId);
if (existing != null && existing > 0) {
if (strict) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.already.posted",
incomeGl.get("name") + " has already been closed to profit for " + financialYear + ".");
}
out.put("posted", false);
out.put("reason", "Already closed for " + financialYear);
return out;
}
final BigDecimal balance = balanceOf(incomeGlId, range);
if (balance.compareTo(BigDecimal.ZERO) <= 0) {
if (strict) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.post",
"There is no income in " + incomeGl.get("name") + " to close for " + financialYear + ".");
}
out.put("posted", false);
out.put("reason", "No income to close for " + financialYear);
return out;
}
final int entryCount = entryCountOf(incomeGlId, range);
final String currencyCode = resolveCurrency(incomeGlId);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate postingDate = range[1];
final String txnId = "YEPROFIT-" + System.nanoTime();
final String desc = "Year-end closing " + financialYear + " — " + incomeGl.get("name") + " to profit"
+ (note != null ? " | " + note : "");
insertEntry(officeId, incomeGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, desc);
insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
"Profit for " + financialYear + " from " + incomeGl.get("name"));
jdbcTemplate.update(
"INSERT INTO m_income_profit_transfer "
+ "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
+ " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
financialYear, incomeGlId, profitGlId, officeId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance,
entryCount, currencyCode, txnId, java.sql.Date.valueOf(postingDate), userId, userName, note);
log.info("Year-end profit posting: {} {} from {} to {} for {}", balance, currencyCode, incomeGl.get("name"), profitGl.get("name"),
financialYear);
out.put("posted", true);
out.put("amountPostedToProfit", balance);
out.put("entryCount", entryCount);
out.put("postedOnDate", postingDate.toString());
out.put("glTransactionId", txnId);
out.put("currency", currencyCode);
out.put("message", balance + " " + currencyCode + " posted to " + profitGl.get("name") + " for " + financialYear + ".");
return out;
}
/**
* Closes EVERY income and expense GL into Bank Profit for the year, leaving the equity GL holding the year's real
* net profit.
*
* <pre>
* each income GL: Dr Income GL Cr Bank Profit (income raises profit)
* each expense GL: Dr Bank Profit Cr Expense GL (expense reduces profit)
* </pre>
*
* {@link #postToProfit} closes one named income GL and is what the legacy share-entry-fee job calls; this closes
* the whole P&L, which is what an actual year-end close is. Anything reading Bank Profit as "the year's profit" —
* the share dividend declaration in particular — depends on this having run, because a partial close leaves the
* profit split across two places and understates it.
*
* Each GL still gets its own m_income_profit_transfer row, so the same "once per year per GL" guard applies: a
* re-run after new GL accounts appear closes only the ones not yet closed, and never double-posts one that was.
* Accounts with a nil balance are skipped rather than posted as zero entries.
*/
@Transactional
public Map<String, Object> closeAllToProfit(final String financialYear, final Long userId, final String userName, final String note,
final boolean strict) {
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
final Long profitGlId = toLong(profitGl.get("id"));
final LocalDate[] range = financialYearRange(financialYear);
final Long officeId = headOfficeId();
// 4 = INCOME, 5 = EXPENSE (GLAccountType). Disabled accounts are excluded: they can hold a
// historical balance but must not attract new postings.
final List<Map<String, Object>> plAccounts = jdbcTemplate.queryForList(
"SELECT id, gl_code, name, classification_enum FROM acc_gl_account "
+ " WHERE classification_enum IN (4, 5) AND disabled = false ORDER BY classification_enum, gl_code");
final List<Map<String, Object>> closed = new ArrayList<>();
final List<Map<String, Object>> skipped = new ArrayList<>();
BigDecimal totalIncome = BigDecimal.ZERO;
BigDecimal totalExpense = BigDecimal.ZERO;
for (final Map<String, Object> gl : plAccounts) {
final Long glId = toLong(gl.get("id"));
final boolean isIncome = ((Number) gl.get("classification_enum")).intValue() == 4;
final Integer existing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ? AND income_gl_id = ? AND office_id = ?",
Integer.class, financialYear, glId, officeId);
if (existing != null && existing > 0) {
skipped.add(skipRow(gl, "Already closed for " + financialYear));
continue;
}
// balanceOf returns credit-less-debit. For an income GL that is the income earned; for an
// expense GL the natural balance is a debit, so the same figure comes back negative and
// is flipped here to read as a positive expense.
final BigDecimal rawBalance = balanceOf(glId, range);
final BigDecimal balance = isIncome ? rawBalance : rawBalance.negate();
if (balance.compareTo(BigDecimal.ZERO) <= 0) {
skipped.add(skipRow(gl, "Nothing to close for " + financialYear));
continue;
}
final int entryCount = entryCountOf(glId, range);
final String currencyCode = resolveCurrency(glId);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate postingDate = range[1];
final String txnId = "YECLOSE-" + financialYear + "-" + gl.get("gl_code") + "-" + System.nanoTime();
final String descr = "Year-end closing " + financialYear + " — " + gl.get("name") + " to profit"
+ (note != null ? " | " + note : "");
if (isIncome) {
insertEntry(officeId, glId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
insertEntry(officeId, profitGlId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode,
"Profit for " + financialYear + " from " + gl.get("name"));
totalIncome = totalIncome.add(balance);
} else {
insertEntry(officeId, glId, CREDIT, balance, txnId, postingDate, userId, now, currencyCode, descr);
insertEntry(officeId, profitGlId, DEBIT, balance, txnId, postingDate, userId, now, currencyCode,
"Expense for " + financialYear + " from " + gl.get("name"));
totalExpense = totalExpense.add(balance);
}
jdbcTemplate.update("INSERT INTO m_income_profit_transfer "
+ "(financial_year, income_gl_id, profit_gl_id, office_id, from_date, to_date, amount, entry_count, "
+ " currency_code, gl_transaction_id, posted_on_date, posted_by_user, posted_by_name, note) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", financialYear, glId, profitGlId, officeId,
java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]), balance, entryCount, currencyCode, txnId,
java.sql.Date.valueOf(postingDate), userId, userName, note);
final Map<String, Object> row = new LinkedHashMap<>();
row.put("glCode", gl.get("gl_code"));
row.put("glName", gl.get("name"));
row.put("type", isIncome ? "INCOME" : "EXPENSE");
row.put("amount", balance);
row.put("entryCount", entryCount);
row.put("glTransactionId", txnId);
closed.add(row);
}
if (closed.isEmpty() && strict) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.nothing.to.close",
"There is nothing left to close to profit for " + financialYear + ".");
}
final BigDecimal netProfit = totalIncome.subtract(totalExpense);
log.info("Year-end close {}: income {}, expense {}, net profit {} posted to {}", financialYear, totalIncome, totalExpense,
netProfit, profitGl.get("name"));
final Map<String, Object> out = new LinkedHashMap<>();
out.put("financialYear", financialYear);
out.put("fromDate", range[0].toString());
out.put("toDate", range[1].toString());
out.put("profitGlCode", profitGl.get("gl_code"));
out.put("profitGlName", profitGl.get("name"));
out.put("totalIncomeClosed", totalIncome);
out.put("totalExpenseClosed", totalExpense);
out.put("netProfitPosted", netProfit);
out.put("accountsClosed", closed);
out.put("accountsSkipped", skipped);
out.put("message", closed.size() + " account(s) closed to " + profitGl.get("name") + " for " + financialYear + "; net profit "
+ netProfit + ".");
return out;
}
private Map<String, Object> skipRow(final Map<String, Object> gl, final String reason) {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("glCode", gl.get("gl_code"));
row.put("glName", gl.get("name"));
row.put("reason", reason);
return row;
}
/**
* The year's profit as it stands in the Bank Profit equity GL — what a dividend may be declared out of once the
* year has been closed.
*
* Only entries dated inside the financial year count, so each year's profit is separable rather than being one
* ever-growing equity balance. Returns zero (not an error) when the year has not been closed yet; callers decide
* whether that is a blocking condition.
*/
public BigDecimal profitForFinancialYear(final String financialYear) {
final LocalDate[] range = financialYearRange(financialYear);
final Map<String, Object> profitGl = loadGlByCode(PROFIT_GL_CODE);
final BigDecimal balance = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false AND entry_date BETWEEN ? AND ?",
BigDecimal.class, CREDIT, toLong(profitGl.get("id")), java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
return balance == null ? BigDecimal.ZERO : balance;
}
/** Whether the year's P&L has been closed into Bank Profit at all. */
public boolean isYearClosed(final String financialYear) {
final Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_income_profit_transfer WHERE financial_year = ?",
Integer.class, financialYear);
return count != null && count > 0;
}
/** Net credit balance sitting in the income GL for the year. */
public BigDecimal balanceOf(final Long incomeGlId, final LocalDate[] range) {
final BigDecimal balance = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(CASE WHEN type_enum = ? THEN amount ELSE -amount END), 0) "
+ " FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false " + " AND entry_date BETWEEN ? AND ?",
BigDecimal.class, CREDIT, incomeGlId, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
return balance == null ? BigDecimal.ZERO : balance;
}
/** How many fee postings made up that balance. */
public int entryCountOf(final Long incomeGlId, final LocalDate[] range) {
final Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM acc_gl_journal_entry WHERE account_id = ? AND reversed = false "
+ " AND type_enum = ? AND entry_date BETWEEN ? AND ?",
Integer.class, incomeGlId, CREDIT, java.sql.Date.valueOf(range[0]), java.sql.Date.valueOf(range[1]));
return count == null ? 0 : count;
}
/** Indian financial year label for today, e.g. "2026-27" for 19 Aug 2026. */
public String currentFinancialYear() {
final LocalDate today = DateUtils.getBusinessLocalDate();
final int startYear = today.getMonthValue() >= 4 ? today.getYear() : today.getYear() - 1;
return startYear + "-" + String.format("%02d", (startYear + 1) % 100);
}
/** 1 April to 31 March for a "YYYY-YY" label. */
public LocalDate[] financialYearRange(final String financialYear) {
try {
final int startYear = Integer.parseInt(financialYear.substring(0, 4));
return new LocalDate[] { LocalDate.of(startYear, 4, 1), LocalDate.of(startYear + 1, 3, 31) };
} catch (RuntimeException e) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.year.invalid",
"financialYear must look like 2026-27. Received: " + financialYear);
}
}
public Map<String, Object> loadGlByCode(final String glCode) {
try {
return jdbcTemplate.queryForMap("SELECT id, gl_code, name, classification_enum FROM acc_gl_account WHERE gl_code = ?", glCode);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.incomeprofit.gl.not.found", "GL account " + glCode + " does not exist.");
}
}
public Long headOfficeId() {
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
}
private String resolveCurrency(final Long glAccountId) {
try {
final String code = jdbcTemplate.queryForObject(
"SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class,
glAccountId);
return code == null ? "INR" : code;
} catch (EmptyResultDataAccessException e) {
return "INR";
}
}
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, "
+ " created_by, last_modified_by, created_date, lastmodified_date, "
+ " is_running_balance_calculated, office_running_balance, "
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, userId, userId,
now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), now.atOffset(ZoneOffset.UTC),
now.atOffset(ZoneOffset.UTC));
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
}
@@ -1,104 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.api;
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.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.agent.data.AgentData;
import org.apache.fineract.organisation.agent.service.AgentReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/agents")
@Component
@Tag(name = "DDS Agents", description = "A DDS collection agent - either a person or, at the top of the hierarchy, the bank itself - with its own settlement savings account and guarantors.")
@RequiredArgsConstructor
public class AgentsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "AGENT";
private final PlatformSecurityContext context;
private final AgentReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Agents", description = "Returns the list of DDS agents.")
public Collection<AgentData> retrieveAll() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll();
}
@GET
@Path("{agentId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a DDS Agent", description = "Returns the details of a DDS agent, including its guarantors.")
public AgentData retrieveOne(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveOne(agentId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a DDS Agent", description = "Mandatory fields: clientId, agentNameId, agentAddress1, agentCity, "
+ "agentPinCode, agentSavingAccountType, agentSavingAccountNo, agentOpeningDate, agentBirthDate, homeBranch, "
+ "savingBranch, tdsApplicable, commissionCalculation\n" + "\n"
+ "Optional fields: agentAddress2, agentTelephone, agentOpeningBalance, tdsPercent, transferBranch, "
+ "transferAmountAccountType, transferAccountNo, limit, machineRent, agentSecurityBranch, agentSecurityAccountType, "
+ "agentSecurityAccountNo, otherCommission, agentDeposit, festivalAdvance, otherDeduction, limits, guarantors")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createAgent() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@PUT
@Path("{agentId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a DDS Agent", description = "Updates the details of a DDS agent.")
public CommandProcessingResult update(@PathParam("agentId") @Parameter(description = "agentId") final Long agentId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.updateAgent(agentId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -1,36 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface AgentRepository extends JpaRepository<Agent, Long> {
@Query("select coalesce(max(cast(substring(a.agentCode, 3) as long)), 0) from Agent a where a.agentCode like 'AG%'")
Long retrieveMaxAgentCodeSuffix();
Optional<Agent> findByAgentCode(String agentCode);
@Query("select a from Agent a where a.id between :agentFrom and :agentTo")
List<Agent> findBetweenIds(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo);
}
@@ -1,46 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "AGENT", action = "CREATE")
public class CreateAgentCommandHandler implements NewCommandSourceHandler {
private final AgentWritePlatformService writePlatformService;
@Autowired
public CreateAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createAgent(command);
}
}
@@ -1,46 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "AGENT", action = "UPDATE")
public class UpdateAgentCommandHandler implements NewCommandSourceHandler {
private final AgentWritePlatformService writePlatformService;
@Autowired
public UpdateAgentCommandHandler(final AgentWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.updateAgent(command.entityId(), command);
}
}
@@ -1,409 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.serialization;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* Validates create/update commands for an Agent - the DDS collection agent. As of 2026-08-24, Agent
* does the daily collection work directly (the SubAgent tier was removed), so this deserializer carries
* the collection/commission/limit/security-account fields SubAgent used to own, alongside Agent's
* original identity/settlement-account fields.
*/
@Component
public final class AgentCommandFromApiJsonDeserializer {
public static final String CLIENT_ID = "clientId";
public static final String AGENT_NAME_ID = "agentNameId";
public static final String AGENT_ADDRESS1 = "agentAddress1";
public static final String AGENT_ADDRESS2 = "agentAddress2";
public static final String AGENT_CITY = "agentCity";
public static final String AGENT_PINCODE = "agentPinCode";
public static final String AGENT_TELEPHONE = "agentTelephone";
public static final String AGENT_SAVING_ACCOUNT_TYPE = "agentSavingAccountType";
public static final String AGENT_SAVING_ACCOUNT_NO = "agentSavingAccountNo";
public static final String AGENT_OPENING_DATE = "agentOpeningDate";
public static final String AGENT_BIRTH_DATE = "agentBirthDate";
public static final String AGENT_OPENING_BALANCE = "agentOpeningBalance";
public static final String HOME_BRANCH = "homeBranch";
public static final String SAVING_BRANCH = "savingBranch";
public static final String TDS_APPLICABLE = "tdsApplicable";
public static final String TDS_PERCENT = "tdsPercent";
public static final String TRANSFER_BRANCH = "transferBranch";
public static final String TRANSFER_AMOUNT_ACCOUNT_TYPE = "transferAmountAccountType";
public static final String TRANSFER_ACCOUNT_NO = "transferAccountNo";
public static final String COMMISSION_CALCULATION = "commissionCalculation";
public static final String LIMIT = "limit";
public static final String MACHINE_RENT = "machineRent";
public static final String AGENT_SECURITY_BRANCH = "agentSecurityBranch";
public static final String AGENT_SECURITY_ACCOUNT_TYPE = "agentSecurityAccountType";
public static final String AGENT_SECURITY_ACCOUNT_NO = "agentSecurityAccountNo";
public static final String OTHER_COMMISSION = "otherCommission";
public static final String AGENT_DEPOSIT = "agentDeposit";
public static final String FESTIVAL_ADVANCE = "festivalAdvance";
public static final String OTHER_DEDUCTION = "otherDeduction";
public static final String LIMITS = "limits";
public static final String GUARANTORS = "guarantors";
public static final String DATE_FORMAT = "dateFormat";
public static final String LOCALE = "locale";
private static final String GUARANTOR_CLIENT_ID = "clientId";
private static final String GUARANTOR_NAME = "guarantorName";
private static final String GUARANTOR_ADDRESS1 = "address1";
private static final String GUARANTOR_ADDRESS2 = "address2";
private static final String GUARANTOR_CITY = "city";
private static final String GUARANTOR_PINCODE = "pinCode";
private static final String GUARANTOR_TELEPHONE = "telephone";
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList(CLIENT_ID, AGENT_NAME_ID, AGENT_ADDRESS1,
AGENT_ADDRESS2, AGENT_CITY, AGENT_PINCODE, AGENT_TELEPHONE, AGENT_SAVING_ACCOUNT_TYPE, AGENT_SAVING_ACCOUNT_NO,
AGENT_OPENING_DATE, AGENT_BIRTH_DATE, AGENT_OPENING_BALANCE, HOME_BRANCH, SAVING_BRANCH, TDS_APPLICABLE, TDS_PERCENT,
TRANSFER_BRANCH, TRANSFER_AMOUNT_ACCOUNT_TYPE, TRANSFER_ACCOUNT_NO, COMMISSION_CALCULATION, LIMIT, MACHINE_RENT,
AGENT_SECURITY_BRANCH, AGENT_SECURITY_ACCOUNT_TYPE, AGENT_SECURITY_ACCOUNT_NO, OTHER_COMMISSION, AGENT_DEPOSIT,
FESTIVAL_ADVANCE, OTHER_DEDUCTION, LIMITS, GUARANTORS, DATE_FORMAT, LOCALE));
private static final Set<String> SUPPORTED_LIMIT_PARAMETERS = new HashSet<>(Arrays.asList("fromDate", "toDate", "limit"));
private static final Set<String> SUPPORTED_GUARANTOR_PARAMETERS = new HashSet<>(Arrays.asList(GUARANTOR_CLIENT_ID, GUARANTOR_NAME,
GUARANTOR_ADDRESS1, GUARANTOR_ADDRESS2, GUARANTOR_CITY, GUARANTOR_PINCODE, GUARANTOR_TELEPHONE));
private final FromJsonHelper fromApiJsonHelper;
@Autowired
public AgentCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
final JsonElement element = this.fromApiJsonHelper.parse(json);
final Long clientId = this.fromApiJsonHelper.extractLongNamed(CLIENT_ID, element);
baseDataValidator.reset().parameter(CLIENT_ID).value(clientId).notNull().integerGreaterThanZero();
final String agentName = this.fromApiJsonHelper.extractStringNamed(AGENT_NAME_ID, element);
baseDataValidator.reset().parameter(AGENT_NAME_ID).value(agentName).notBlank().notExceedingLengthOf(100);
final String agentAddress1 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS1, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS1).value(agentAddress1).notBlank().notExceedingLengthOf(200);
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS2, element)) {
final String agentAddress2 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS2, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS2).value(agentAddress2).ignoreIfNull().notExceedingLengthOf(200);
}
final String agentCity = this.fromApiJsonHelper.extractStringNamed(AGENT_CITY, element);
baseDataValidator.reset().parameter(AGENT_CITY).value(agentCity).notBlank().notExceedingLengthOf(100);
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
}
final String agentSavingAccountType = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_TYPE, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_TYPE).value(agentSavingAccountType).notBlank()
.notExceedingLengthOf(100);
final String agentSavingAccountNo = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_NO, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_NO).value(agentSavingAccountNo).notBlank().notExceedingLengthOf(50);
final var agentOpeningDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_OPENING_DATE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_DATE).value(agentOpeningDate).notNull();
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
final var agentOpeningBalance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_OPENING_BALANCE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
}
final String homeBranch = this.fromApiJsonHelper.extractStringNamed(HOME_BRANCH, element);
baseDataValidator.reset().parameter(HOME_BRANCH).value(homeBranch).notBlank().notExceedingLengthOf(100);
final String savingBranch = this.fromApiJsonHelper.extractStringNamed(SAVING_BRANCH, element);
baseDataValidator.reset().parameter(SAVING_BRANCH).value(savingBranch).notBlank().notExceedingLengthOf(100);
baseDataValidator.reset().parameter(TDS_APPLICABLE).trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(TDS_APPLICABLE,
element));
baseDataValidator.reset().parameter(COMMISSION_CALCULATION)
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(COMMISSION_CALCULATION, element));
validateOptionalStrings(element, baseDataValidator);
if (this.fromApiJsonHelper.parameterExists(LIMITS, element)) {
validateLimits(element.getAsJsonObject(), dataValidationErrors);
}
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
public void validateForUpdate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent");
final JsonElement element = this.fromApiJsonHelper.parse(json);
if (this.fromApiJsonHelper.parameterExists(CLIENT_ID, element)) {
final Long clientId = this.fromApiJsonHelper.extractLongNamed(CLIENT_ID, element);
baseDataValidator.reset().parameter(CLIENT_ID).value(clientId).notNull().integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_NAME_ID, element)) {
final String agentName = this.fromApiJsonHelper.extractStringNamed(AGENT_NAME_ID, element);
baseDataValidator.reset().parameter(AGENT_NAME_ID).value(agentName).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS1, element)) {
final String agentAddress1 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS1, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS1).value(agentAddress1).notBlank().notExceedingLengthOf(200);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_ADDRESS2, element)) {
final String agentAddress2 = this.fromApiJsonHelper.extractStringNamed(AGENT_ADDRESS2, element);
baseDataValidator.reset().parameter(AGENT_ADDRESS2).value(agentAddress2).ignoreIfNull().notExceedingLengthOf(200);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_CITY, element)) {
final String agentCity = this.fromApiJsonHelper.extractStringNamed(AGENT_CITY, element);
baseDataValidator.reset().parameter(AGENT_CITY).value(agentCity).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_PINCODE, element)) {
final String agentPinCode = this.fromApiJsonHelper.extractStringNamed(AGENT_PINCODE, element);
baseDataValidator.reset().parameter(AGENT_PINCODE).value(agentPinCode).notBlank().notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_TELEPHONE, element)) {
final String agentTelephone = this.fromApiJsonHelper.extractStringNamed(AGENT_TELEPHONE, element);
baseDataValidator.reset().parameter(AGENT_TELEPHONE).value(agentTelephone).ignoreIfNull().notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SAVING_ACCOUNT_TYPE, element)) {
final String agentSavingAccountType = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_TYPE, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_TYPE).value(agentSavingAccountType).notBlank()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SAVING_ACCOUNT_NO, element)) {
final String agentSavingAccountNo = this.fromApiJsonHelper.extractStringNamed(AGENT_SAVING_ACCOUNT_NO, element);
baseDataValidator.reset().parameter(AGENT_SAVING_ACCOUNT_NO).value(agentSavingAccountNo).notBlank().notExceedingLengthOf(50);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_DATE, element)) {
final var agentOpeningDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_OPENING_DATE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_DATE).value(agentOpeningDate).notNull();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_BIRTH_DATE, element)) {
final var agentBirthDate = this.fromApiJsonHelper.extractLocalDateNamed(AGENT_BIRTH_DATE, element);
baseDataValidator.reset().parameter(AGENT_BIRTH_DATE).value(agentBirthDate).notNull();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_OPENING_BALANCE, element)) {
final var agentOpeningBalance = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_OPENING_BALANCE, element);
baseDataValidator.reset().parameter(AGENT_OPENING_BALANCE).value(agentOpeningBalance).ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(HOME_BRANCH, element)) {
final String homeBranch = this.fromApiJsonHelper.extractStringNamed(HOME_BRANCH, element);
baseDataValidator.reset().parameter(HOME_BRANCH).value(homeBranch).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(SAVING_BRANCH, element)) {
final String savingBranch = this.fromApiJsonHelper.extractStringNamed(SAVING_BRANCH, element);
baseDataValidator.reset().parameter(SAVING_BRANCH).value(savingBranch).notBlank().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(TDS_APPLICABLE, element)) {
baseDataValidator.reset().parameter(TDS_APPLICABLE)
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(TDS_APPLICABLE, element));
}
if (this.fromApiJsonHelper.parameterExists(COMMISSION_CALCULATION, element)) {
baseDataValidator.reset().parameter(COMMISSION_CALCULATION)
.trueOrFalseRequired(this.fromApiJsonHelper.extractStringNamed(COMMISSION_CALCULATION, element));
}
validateOptionalStrings(element, baseDataValidator);
if (this.fromApiJsonHelper.parameterExists(LIMITS, element)) {
validateLimits(element.getAsJsonObject(), dataValidationErrors);
}
if (this.fromApiJsonHelper.parameterExists(GUARANTORS, element)) {
validateGuarantors(element.getAsJsonObject(), dataValidationErrors);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private void validateOptionalStrings(final JsonElement element, final DataValidatorBuilder v) {
if (this.fromApiJsonHelper.parameterExists(TDS_PERCENT, element)) {
v.reset().parameter(TDS_PERCENT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TDS_PERCENT, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(TRANSFER_BRANCH, element)) {
v.reset().parameter(TRANSFER_BRANCH).value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_BRANCH, element))
.ignoreIfNull().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(TRANSFER_AMOUNT_ACCOUNT_TYPE, element)) {
v.reset().parameter(TRANSFER_AMOUNT_ACCOUNT_TYPE)
.value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_AMOUNT_ACCOUNT_TYPE, element)).ignoreIfNull()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(TRANSFER_ACCOUNT_NO, element)) {
v.reset().parameter(TRANSFER_ACCOUNT_NO).value(this.fromApiJsonHelper.extractStringNamed(TRANSFER_ACCOUNT_NO, element))
.ignoreIfNull().notExceedingLengthOf(50);
}
if (this.fromApiJsonHelper.parameterExists(LIMIT, element)) {
v.reset().parameter(LIMIT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(LIMIT, element)).ignoreIfNull()
.zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(MACHINE_RENT, element)) {
v.reset().parameter(MACHINE_RENT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(MACHINE_RENT, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_BRANCH, element)) {
v.reset().parameter(AGENT_SECURITY_BRANCH).value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_BRANCH, element))
.ignoreIfNull().notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_ACCOUNT_TYPE, element)) {
v.reset().parameter(AGENT_SECURITY_ACCOUNT_TYPE)
.value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_ACCOUNT_TYPE, element)).ignoreIfNull()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists(AGENT_SECURITY_ACCOUNT_NO, element)) {
v.reset().parameter(AGENT_SECURITY_ACCOUNT_NO)
.value(this.fromApiJsonHelper.extractStringNamed(AGENT_SECURITY_ACCOUNT_NO, element)).ignoreIfNull()
.notExceedingLengthOf(50);
}
if (this.fromApiJsonHelper.parameterExists(OTHER_COMMISSION, element)) {
v.reset().parameter(OTHER_COMMISSION).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(OTHER_COMMISSION, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(AGENT_DEPOSIT, element)) {
v.reset().parameter(AGENT_DEPOSIT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AGENT_DEPOSIT, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(FESTIVAL_ADVANCE, element)) {
v.reset().parameter(FESTIVAL_ADVANCE).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(FESTIVAL_ADVANCE, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(OTHER_DEDUCTION, element)) {
v.reset().parameter(OTHER_DEDUCTION).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(OTHER_DEDUCTION, element))
.ignoreIfNull().zeroOrPositiveAmount();
}
}
private void validateLimits(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("agent.limit");
final var limitsArray = topLevelJson.getAsJsonArray(LIMITS);
for (int i = 0; i < limitsArray.size(); i++) {
final JsonObject limitJson = limitsArray.get(i).getAsJsonObject();
this.fromApiJsonHelper.checkForUnsupportedParameters(limitJson, SUPPORTED_LIMIT_PARAMETERS);
v.reset().parameterAtIndexArray("fromDate", i).value(extractString(limitJson, "fromDate")).notBlank();
v.reset().parameterAtIndexArray("toDate", i).value(extractString(limitJson, "toDate")).notBlank();
v.reset().parameterAtIndexArray("limit", i).value(extractBigDecimal(limitJson, "limit")).notNull().zeroOrPositiveAmount();
}
}
private void validateGuarantors(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
final DataValidatorBuilder guarantorValidator = new DataValidatorBuilder(dataValidationErrors).resource("agent.guarantor");
final var guarantorsArray = topLevelJson.getAsJsonArray(GUARANTORS);
for (int i = 0; i < guarantorsArray.size(); i++) {
final JsonObject guarantorJson = guarantorsArray.get(i).getAsJsonObject();
this.fromApiJsonHelper.checkForUnsupportedParameters(guarantorJson, SUPPORTED_GUARANTOR_PARAMETERS);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CLIENT_ID, i)
.value(extractLong(guarantorJson, GUARANTOR_CLIENT_ID)).notNull().integerGreaterThanZero();
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_NAME, i).value(extractString(guarantorJson, GUARANTOR_NAME))
.notBlank().notExceedingLengthOf(100);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_ADDRESS1, i).value(extractString(guarantorJson, GUARANTOR_ADDRESS1))
.notBlank().notExceedingLengthOf(200);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_CITY, i).value(extractString(guarantorJson, GUARANTOR_CITY))
.notBlank().notExceedingLengthOf(100);
guarantorValidator.reset().parameterAtIndexArray(GUARANTOR_PINCODE, i).value(extractString(guarantorJson, GUARANTOR_PINCODE))
.notBlank().notExceedingLengthOf(20);
}
}
private String extractString(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
}
private Long extractLong(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsLong() : null;
}
private java.math.BigDecimal extractBigDecimal(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null;
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -1,29 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.service;
import java.util.Collection;
import org.apache.fineract.organisation.agent.data.AgentData;
public interface AgentReadPlatformService {
Collection<AgentData> retrieveAll();
AgentData retrieveOne(Long agentId);
}
@@ -1,176 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.organisation.agent.data.AgentData;
import org.apache.fineract.organisation.agent.data.AgentGuarantorData;
import org.apache.fineract.organisation.agent.data.AgentLimitData;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@RequiredArgsConstructor
public class AgentReadPlatformServiceImpl implements AgentReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final AgentMapper AGENT_MAPPER = new AgentMapper();
private static final AgentLimitMapper LIMIT_MAPPER = new AgentLimitMapper();
private static final AgentGuarantorMapper GUARANTOR_MAPPER = new AgentGuarantorMapper();
private static final class AgentMapper implements RowMapper<AgentData> {
public String schema() {
return " a.id as id, a.client_id as clientId, c.display_name as clientName, a.agent_code as agentCode, "
+ " a.agent_name as agentNameId, a.agent_address1 as agentAddress1, a.agent_address2 as agentAddress2, "
+ " a.agent_city as agentCity, a.agent_pincode as agentPinCode, a.agent_telephone as agentTelephone, "
+ " a.agent_saving_account_type as agentSavingAccountType, a.agent_saving_account_no as agentSavingAccountNo, "
+ " a.agent_opening_date as agentOpeningDate, a.agent_birth_date as agentBirthDate, "
+ " a.agent_current_balance as agentCurrentBalance, a.agent_opening_balance as agentOpeningBalance, "
+ " a.home_branch as homeBranch, a.saving_branch as savingBranch, a.tds_applicable as tdsApplicable, "
+ " a.tds_percent as tdsPercent, a.current_tds as currentTds, a.tds_amount as tdsAmount, "
+ " a.transfer_branch as transferBranch, a.transfer_amount_account_type as transferAmountAccountType, "
+ " a.transfer_account_no as transferAccountNo, a.collector_commission_calculation as commissionCalculation, "
+ " a.collector_limit_amount as limitAmount, a.machine_rent as machineRent, "
+ " a.agent_security_branch as agentSecurityBranch, a.agent_security_account_type as agentSecurityAccountType, "
+ " a.agent_security_account_no as agentSecurityAccountNo, a.commission as commission, "
+ " a.other_commission as otherCommission, a.agent_deposit as agentDeposit, "
+ " a.festival_advance as festivalAdvance, a.other_deduction as otherDeduction "
+ " from m_dds_agent a join m_client c on c.id = a.client_id";
}
@Override
public AgentData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return AgentData.instance(rs.getLong("id"), rs.getLong("clientId"), rs.getString("clientName"), rs.getString("agentCode"),
rs.getString("agentNameId"), rs.getString("agentAddress1"), rs.getString("agentAddress2"),
rs.getString("agentCity"), rs.getString("agentPinCode"), rs.getString("agentTelephone"),
rs.getString("agentSavingAccountType"), rs.getString("agentSavingAccountNo"),
JdbcSupport.getLocalDate(rs, "agentOpeningDate"), JdbcSupport.getLocalDate(rs, "agentBirthDate"),
rs.getBigDecimal("agentCurrentBalance"), rs.getBigDecimal("agentOpeningBalance"), rs.getString("homeBranch"),
rs.getString("savingBranch"), rs.getBoolean("tdsApplicable"), rs.getBigDecimal("tdsPercent"),
rs.getBigDecimal("currentTds"), rs.getBigDecimal("tdsAmount"), rs.getString("transferBranch"),
rs.getString("transferAmountAccountType"), rs.getString("transferAccountNo"),
rs.getBoolean("commissionCalculation"), rs.getBigDecimal("limitAmount"), rs.getBigDecimal("machineRent"),
rs.getString("agentSecurityBranch"), rs.getString("agentSecurityAccountType"),
rs.getString("agentSecurityAccountNo"), rs.getBigDecimal("commission"), rs.getBigDecimal("otherCommission"),
rs.getBigDecimal("agentDeposit"), rs.getBigDecimal("festivalAdvance"), rs.getBigDecimal("otherDeduction"),
new ArrayList<>(), new ArrayList<>());
}
}
private static final class AgentLimitMapper implements RowMapper<Map.Entry<Long, AgentLimitData>> {
public String schema() {
return " l.agent_id as agentId, l.id as id, l.from_date as fromDate, l.to_date as toDate, "
+ " l.limit_amount as limitAmount from m_dds_agent_limit l";
}
@Override
public Map.Entry<Long, AgentLimitData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long agentId = rs.getLong("agentId");
final AgentLimitData data = AgentLimitData.instance(rs.getLong("id"), JdbcSupport.getLocalDate(rs, "fromDate"),
JdbcSupport.getLocalDate(rs, "toDate"), rs.getBigDecimal("limitAmount"));
return Map.entry(agentId, data);
}
}
private static final class AgentGuarantorMapper implements RowMapper<Map.Entry<Long, AgentGuarantorData>> {
public String schema() {
return " g.agent_id as agentId, g.id as id, g.client_id as clientId, g.guarantor_name as guarantorName, "
+ " g.address1 as address1, g.address2 as address2, g.city as city, g.pin_code as pinCode, "
+ " g.telephone as telephone from m_dds_agent_guarantor g";
}
@Override
public Map.Entry<Long, AgentGuarantorData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long agentId = rs.getLong("agentId");
final AgentGuarantorData data = AgentGuarantorData.instance(rs.getLong("id"), rs.getLong("clientId"),
rs.getString("guarantorName"), rs.getString("address1"), rs.getString("address2"), rs.getString("city"),
rs.getString("pinCode"), rs.getString("telephone"));
return Map.entry(agentId, data);
}
}
@Override
public Collection<AgentData> retrieveAll() {
final String sql = "select " + AGENT_MAPPER.schema() + " order by a.id";
final List<AgentData> agents = this.jdbcTemplate.query(sql, AGENT_MAPPER); // NOSONAR
return attachChildren(agents);
}
@Override
public AgentData retrieveOne(final Long agentId) {
try {
final String sql = "select " + AGENT_MAPPER.schema() + " where a.id = ?";
final AgentData agent = this.jdbcTemplate.queryForObject(sql, AGENT_MAPPER, agentId); // NOSONAR
return attachChildren(List.of(agent)).iterator().next();
} catch (final EmptyResultDataAccessException e) {
throw new AgentNotFoundException(agentId, e);
}
}
private Collection<AgentData> attachChildren(final List<AgentData> agents) {
if (agents.isEmpty()) {
return agents;
}
final String limitSql = "select " + LIMIT_MAPPER.schema() + " order by l.id";
final List<Map.Entry<Long, AgentLimitData>> limitRows = this.jdbcTemplate.query(limitSql, LIMIT_MAPPER); // NOSONAR
final Map<Long, List<AgentLimitData>> limitsByAgentId = new LinkedHashMap<>();
for (final Map.Entry<Long, AgentLimitData> row : limitRows) {
limitsByAgentId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
}
final String guarantorSql = "select " + GUARANTOR_MAPPER.schema() + " order by g.id";
final List<Map.Entry<Long, AgentGuarantorData>> guarantorRows = this.jdbcTemplate.query(guarantorSql, GUARANTOR_MAPPER); // NOSONAR
final Map<Long, List<AgentGuarantorData>> guarantorsByAgentId = new LinkedHashMap<>();
for (final Map.Entry<Long, AgentGuarantorData> row : guarantorRows) {
guarantorsByAgentId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
}
final List<AgentData> result = new ArrayList<>(agents.size());
for (final AgentData agent : agents) {
final List<AgentLimitData> limits = limitsByAgentId.getOrDefault(agent.getId(), new ArrayList<>());
final List<AgentGuarantorData> guarantors = guarantorsByAgentId.getOrDefault(agent.getId(), new ArrayList<>());
result.add(AgentData.instance(agent.getId(), agent.getClientId(), agent.getClientName(), agent.getAgentCode(),
agent.getAgentNameId(), agent.getAgentAddress1(), agent.getAgentAddress2(), agent.getAgentCity(),
agent.getAgentPinCode(), agent.getAgentTelephone(), agent.getAgentSavingAccountType(),
agent.getAgentSavingAccountNo(), agent.getAgentOpeningDate(), agent.getAgentBirthDate(),
agent.getAgentCurrentBalance(), agent.getAgentOpeningBalance(), agent.getHomeBranch(), agent.getSavingBranch(),
agent.getTdsApplicable(), agent.getTdsPercent(), agent.getCurrentTds(), agent.getTdsAmount(),
agent.getTransferBranch(), agent.getTransferAmountAccountType(), agent.getTransferAccountNo(),
agent.getCommissionCalculation(), agent.getLimit(), agent.getMachineRent(), agent.getAgentSecurityBranch(),
agent.getAgentSecurityAccountType(), agent.getAgentSecurityAccountNo(), agent.getCommission(),
agent.getOtherCommission(), agent.getAgentDeposit(), agent.getFestivalAdvance(), agent.getOtherDeduction(),
limits, guarantors));
}
return result;
}
}
@@ -1,29 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface AgentWritePlatformService {
CommandProcessingResult createAgent(JsonCommand command);
CommandProcessingResult updateAgent(Long agentId, JsonCommand command);
}
@@ -1,187 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import jakarta.persistence.PersistenceException;
import java.math.BigDecimal;
import java.time.LocalDate;
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.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.organisation.agent.domain.AgentGuarantor;
import org.apache.fineract.organisation.agent.domain.AgentLimit;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.organisation.agent.exception.AgentNotFoundException;
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePlatformService {
private final AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final AgentRepository agentRepository;
private final ClientRepositoryWrapper clientRepositoryWrapper;
@Transactional
@Override
public CommandProcessingResult createAgent(final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForCreate(command.json());
final Long clientId = command.longValueOfParameterNamed("clientId");
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
final String agentCode = generateNextAgentCode();
final Agent agent = Agent.fromJson(client, agentCode, command);
addLimitsFromCommand(agent, command);
addGuarantorsFromCommand(agent, command);
this.agentRepository.saveAndFlush(agent);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(agent.getId()) //
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
handleAgentDataIntegrityIssues(dve.getMostSpecificCause(), dve);
return CommandProcessingResult.empty();
} catch (final PersistenceException dve) {
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
handleAgentDataIntegrityIssues(throwable, dve);
return CommandProcessingResult.empty();
}
}
@Transactional
@Override
public CommandProcessingResult updateAgent(final Long agentId, final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForUpdate(command.json());
final Agent agentForUpdate = this.agentRepository.findById(agentId).orElseThrow(() -> new AgentNotFoundException(agentId));
final Map<String, Object> changesOnly = agentForUpdate.update(command);
if (changesOnly.containsKey("clientId")) {
final Long newClientId = (Long) changesOnly.get("clientId");
final Client newClient = this.clientRepositoryWrapper.findOneWithNotFoundDetection(newClientId);
agentForUpdate.changeClient(newClient);
}
if (command.parameterExists("limits")) {
agentForUpdate.resetLimits();
addLimitsFromCommand(agentForUpdate, command);
changesOnly.put("limits", command.jsonFragment("limits"));
}
if (command.parameterExists("guarantors")) {
agentForUpdate.resetGuarantors();
addGuarantorsFromCommand(agentForUpdate, command);
changesOnly.put("guarantors", command.jsonFragment("guarantors"));
}
this.agentRepository.saveAndFlush(agentForUpdate);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(agentId).with(changesOnly)
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
handleAgentDataIntegrityIssues(dve.getMostSpecificCause(), dve);
return CommandProcessingResult.empty();
} catch (final PersistenceException dve) {
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
handleAgentDataIntegrityIssues(throwable, dve);
return CommandProcessingResult.empty();
}
}
private void addLimitsFromCommand(final Agent agent, final JsonCommand command) {
if (!command.parameterExists("limits")) {
return;
}
final JsonArray limitsArray = command.arrayOfParameterNamed("limits");
if (limitsArray == null) {
return;
}
for (int i = 0; i < limitsArray.size(); i++) {
final JsonObject limitJson = limitsArray.get(i).getAsJsonObject();
final LocalDate fromDate = LocalDate.parse(limitJson.get("fromDate").getAsString());
final LocalDate toDate = LocalDate.parse(limitJson.get("toDate").getAsString());
final BigDecimal limit = limitJson.get("limit").getAsBigDecimal();
agent.addLimit(new AgentLimit(fromDate, toDate, limit));
}
}
private void addGuarantorsFromCommand(final Agent agent, final JsonCommand command) {
if (!command.parameterExists("guarantors")) {
return;
}
final JsonArray guarantorsArray = command.arrayOfParameterNamed("guarantors");
if (guarantorsArray == null) {
return;
}
for (int i = 0; i < guarantorsArray.size(); i++) {
final JsonObject guarantorJson = guarantorsArray.get(i).getAsJsonObject();
final Long guarantorClientId = guarantorJson.get("clientId").getAsLong();
final String guarantorName = guarantorJson.get("guarantorName").getAsString();
final String address1 = guarantorJson.get("address1").getAsString();
final String address2 = guarantorJson.has("address2") && !guarantorJson.get("address2").isJsonNull()
? guarantorJson.get("address2").getAsString()
: null;
final String city = guarantorJson.get("city").getAsString();
final String pinCode = guarantorJson.get("pinCode").getAsString();
final String telephone = guarantorJson.has("telephone") && !guarantorJson.get("telephone").isJsonNull()
? guarantorJson.get("telephone").getAsString()
: null;
agent.addGuarantor(new AgentGuarantor(guarantorClientId, guarantorName, address1, address2, city, pinCode, telephone));
}
}
private String generateNextAgentCode() {
final Long maxSuffix = this.agentRepository.retrieveMaxAgentCodeSuffix();
final long nextSuffix = (maxSuffix == null ? 0L : maxSuffix) + 1L;
return String.format("AG%04d", nextSuffix);
}
private void handleAgentDataIntegrityIssues(final Throwable realCause, final Exception dve) {
if (realCause.getMessage() != null && realCause.getMessage().contains("agent_code")) {
throw new PlatformDataIntegrityException("error.msg.agent.duplicate.agentCode", "An agent with the generated code already exists, please retry");
}
log.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, "error.msg.agent.unknown.data.integrity.issue",
"Unknown data integrity issue with resource: " + realCause.getMessage());
}
}
@@ -1,48 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.agent.starter;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.organisation.agent.serialization.AgentCommandFromApiJsonDeserializer;
import org.apache.fineract.organisation.agent.service.AgentReadPlatformService;
import org.apache.fineract.organisation.agent.service.AgentReadPlatformServiceImpl;
import org.apache.fineract.organisation.agent.service.AgentWritePlatformService;
import org.apache.fineract.organisation.agent.service.AgentWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class OrganisationAgentConfiguration {
@Bean
@ConditionalOnMissingBean(AgentReadPlatformService.class)
public AgentReadPlatformService agentReadPlatformService(JdbcTemplate jdbcTemplate) {
return new AgentReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(AgentWritePlatformService.class)
public AgentWritePlatformService agentWritePlatformService(AgentCommandFromApiJsonDeserializer fromApiJsonDeserializer,
AgentRepository agentRepository, ClientRepositoryWrapper clientRepositoryWrapper) {
return new AgentWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, agentRepository, clientRepositoryWrapper);
}
}
@@ -1,74 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.api;
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.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/commissionslabs")
@Component
@Tag(name = "DDS Commission Slabs", description = "The agent commission chart: ACCOUNT slabs (commission percentage on collection volume) and COMMISSION slabs (the override a senior agent earns on a sub-agent's commission). Append-only reference data - no update or delete API.")
@RequiredArgsConstructor
public class CommissionSlabsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "COMMISSIONSLAB";
private final PlatformSecurityContext context;
private final CommissionSlabReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Commission Slabs", description = "Returns the full commission slab chart.")
public Collection<CommissionSlabData> retrieveAll() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll();
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Add DDS Commission Slabs", description = "Mandatory fields: slabs (array of {slabType: ACCOUNT|COMMISSION, "
+ "slabFrom, slabTo, commissionPercent}). Adds new slab rows to the chart - it does not replace or edit existing rows.")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createCommissionSlabs() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -1,31 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.domain;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface CommissionSlabRepository extends JpaRepository<CommissionSlab, Long> {
@Query("select s from CommissionSlab s where s.slabType = :slabType and :amount between s.slabFrom and s.slabTo")
List<CommissionSlab> findMatchingSlabs(@Param("slabType") String slabType, @Param("amount") BigDecimal amount);
}
@@ -1,113 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.serialization;
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;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public final class CommissionSlabCommandFromApiJsonDeserializer {
private static final Set<String> SUPPORTED_SLAB_TYPES = new HashSet<>(Arrays.asList("ACCOUNT", "COMMISSION"));
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("slabs", "dateFormat", "locale"));
private static final Set<String> SUPPORTED_SLAB_PARAMETERS = new HashSet<>(
Arrays.asList("slabType", "slabFrom", "slabTo", "commissionPercent"));
private final FromJsonHelper fromApiJsonHelper;
@Autowired
public CommissionSlabCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("commissionslab");
final JsonElement element = this.fromApiJsonHelper.parse(json);
if (!this.fromApiJsonHelper.parameterExists("slabs", element)) {
v.reset().parameter("slabs").value(null).notNull();
} else {
final JsonObject topLevel = element.getAsJsonObject();
final var slabsArray = topLevel.getAsJsonArray("slabs");
v.reset().parameter("slabs").value(slabsArray).jsonArrayNotEmpty();
for (int i = 0; i < slabsArray.size(); i++) {
final JsonObject slabJson = slabsArray.get(i).getAsJsonObject();
this.fromApiJsonHelper.checkForUnsupportedParameters(slabJson, SUPPORTED_SLAB_PARAMETERS);
final String slabType = extractString(slabJson, "slabType");
v.reset().parameterAtIndexArray("slabType", i).value(slabType).notBlank()
.isOneOfTheseStringValues(SUPPORTED_SLAB_TYPES.toArray());
v.reset().parameterAtIndexArray("slabFrom", i).value(extractBigDecimal(slabJson, "slabFrom")).notNull()
.zeroOrPositiveAmount();
v.reset().parameterAtIndexArray("slabTo", i).value(extractBigDecimal(slabJson, "slabTo")).notNull()
.zeroOrPositiveAmount();
v.reset().parameterAtIndexArray("commissionPercent", i).value(extractBigDecimal(slabJson, "commissionPercent"))
.notNull().zeroOrPositiveAmount();
}
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private String extractString(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
}
private BigDecimal extractBigDecimal(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsBigDecimal() : null;
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -1,27 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.service;
import java.util.Collection;
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
public interface CommissionSlabReadPlatformService {
Collection<CommissionSlabData> retrieveAll();
}
@@ -1,60 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.organisation.commissionslab.data.CommissionSlabData;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@RequiredArgsConstructor
public class CommissionSlabReadPlatformServiceImpl implements CommissionSlabReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final CommissionSlabMapper MAPPER = new CommissionSlabMapper();
private static final class CommissionSlabMapper implements RowMapper<CommissionSlabData> {
public String schema() {
return " s.id as id, s.slab_type as slabType, s.slab_from as slabFrom, s.slab_to as slabTo, "
+ " s.commission_percent as commissionPercent from m_dds_commission_slab s";
}
@Override
public CommissionSlabData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return CommissionSlabData.builder() //
.id(rs.getLong("id")) //
.slabType(rs.getString("slabType")) //
.slabFrom(rs.getBigDecimal("slabFrom")) //
.slabTo(rs.getBigDecimal("slabTo")) //
.commissionPercent(rs.getBigDecimal("commissionPercent")) //
.build();
}
}
@Override
public List<CommissionSlabData> retrieveAll() {
final String sql = "select " + MAPPER.schema() + " order by s.slab_type, s.slab_from";
return this.jdbcTemplate.query(sql, MAPPER); // NOSONAR
}
}
@@ -1,27 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface CommissionSlabWritePlatformService {
CommandProcessingResult createCommissionSlabs(JsonCommand command);
}
@@ -1,78 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import jakarta.persistence.PersistenceException;
import java.util.ArrayList;
import java.util.List;
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.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
import org.apache.fineract.organisation.commissionslab.serialization.CommissionSlabCommandFromApiJsonDeserializer;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements CommissionSlabWritePlatformService {
private final CommissionSlabCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final CommissionSlabRepository commissionSlabRepository;
@Transactional
@Override
public CommandProcessingResult createCommissionSlabs(final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForCreate(command.json());
final List<CommissionSlab> slabs = new ArrayList<>();
final JsonArray slabsArray = command.arrayOfParameterNamed("slabs");
for (int i = 0; i < slabsArray.size(); i++) {
final JsonObject slabJson = slabsArray.get(i).getAsJsonObject();
slabs.add(new CommissionSlab(slabJson.get("slabType").getAsString(), slabJson.get("slabFrom").getAsBigDecimal(),
slabJson.get("slabTo").getAsBigDecimal(), slabJson.get("commissionPercent").getAsBigDecimal()));
}
this.commissionSlabRepository.saveAllAndFlush(slabs);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(slabs.isEmpty() ? null : slabs.get(0).getId()) //
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
log.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, "error.msg.commissionslab.unknown.data.integrity.issue",
"Unknown data integrity issue with resource: " + ExceptionUtils.getRootCauseMessage(dve));
} catch (final PersistenceException dve) {
log.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, "error.msg.commissionslab.unknown.data.integrity.issue",
"Unknown data integrity issue with resource: " + ExceptionUtils.getRootCauseMessage(dve));
}
}
}
@@ -1,47 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.organisation.commissionslab.starter;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
import org.apache.fineract.organisation.commissionslab.serialization.CommissionSlabCommandFromApiJsonDeserializer;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformService;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabReadPlatformServiceImpl;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformService;
import org.apache.fineract.organisation.commissionslab.service.CommissionSlabWritePlatformServiceJpaRepositoryImpl;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class OrganisationCommissionSlabConfiguration {
@Bean
@ConditionalOnMissingBean(CommissionSlabReadPlatformService.class)
public CommissionSlabReadPlatformService commissionSlabReadPlatformService(JdbcTemplate jdbcTemplate) {
return new CommissionSlabReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(CommissionSlabWritePlatformService.class)
public CommissionSlabWritePlatformService commissionSlabWritePlatformService(
CommissionSlabCommandFromApiJsonDeserializer fromApiJsonDeserializer, CommissionSlabRepository commissionSlabRepository) {
return new CommissionSlabWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, commissionSlabRepository);
}
}
@@ -357,9 +357,14 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
accountsSummary.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
accountsSummary.append("curr.display_symbol as currencyDisplaySymbol, ");
accountsSummary.append("sa.product_id as productId, p.name as productName, p.short_name as shortProductName, ");
accountsSummary.append("sa.deposit_type_enum as depositType ");
accountsSummary.append("sa.deposit_type_enum as depositType, ");
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and
// this row's cycle within the chain, so a client's account list can collapse a renewed
// FD into one entry rather than listing every term as a separate account.
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo ");
accountsSummary.append("from m_savings_account sa ");
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id ");
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
accountsSummary.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid ");
@@ -453,9 +458,15 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname,
activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname);
// Null for a savings/current account and for an FD opened before this column existed;
// callers fall back to accountNo, so an FD that has never been renewed still reads as
// its own FD number.
final String fdAccountNumber = rs.getString("fdAccountNumber");
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
lastActiveTransactionDate);
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo);
}
}
@@ -567,8 +578,10 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
// Current accounts have no deposit term, so no FD renewal chain to report.
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate);
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate,
null, null);
}
}
@@ -0,0 +1,299 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.accountregister.api;
import com.google.gson.Gson;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.sql.Date;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Account register for one date: which accounts were opened and which were closed that day, with
* each account's balance as it stood on that date alongside what it holds now.
*
* The dated balance is read from the running balance stamped on the last transaction up to that
* date, rather than recomputed from the account's current figure — a current balance tells you
* nothing about what the account held three weeks ago.
*/
@Path("/v1/accountregister")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class AccountRegisterApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
@GET
public String register(@QueryParam("date") final String dateParam, @QueryParam("section") final String section) {
this.context.authenticatedUser();
final LocalDate asOn;
if (dateParam != null && !dateParam.isBlank()) {
try {
asOn = LocalDate.parse(dateParam);
} catch (final java.time.format.DateTimeParseException e) {
throw new PlatformDataIntegrityException("error.msg.accountregister.date.invalid",
"Date must be in yyyy-MM-dd format.");
}
} else {
asOn = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
}
final String want = section == null || section.isBlank() ? "ALL" : section.toUpperCase();
final List<Map<String, Object>> rows = new ArrayList<>();
if (matches(want, "SAVINGS") || matches(want, "FD") || matches(want, "RD")) {
rows.addAll(depositAccounts(asOn, want));
}
if (matches(want, "SHARE")) {
rows.addAll(shareAccounts(asOn));
}
// Opened and closed counts come from the rows themselves rather than separate COUNT queries,
// so the totals can never disagree with the list they summarise.
int opened = 0;
int closed = 0;
BigDecimal balanceOnDate = BigDecimal.ZERO;
BigDecimal currentBalance = BigDecimal.ZERO;
for (final Map<String, Object> r : rows) {
if (Boolean.TRUE.equals(r.get("openedOnDate"))) {
opened++;
}
if (Boolean.TRUE.equals(r.get("closedOnDate"))) {
closed++;
}
balanceOnDate = balanceOnDate.add(dec(r.get("balanceOnDate")));
currentBalance = currentBalance.add(dec(r.get("currentBalance")));
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("date", asOn.toString());
out.put("section", want);
out.put("accounts", rows);
out.put("count", rows.size());
out.put("openedCount", opened);
out.put("closedCount", closed);
out.put("totalBalanceOnDate", balanceOnDate);
out.put("totalCurrentBalance", currentBalance);
return gson.toJson(out);
}
private static boolean matches(final String want, final String section) {
return "ALL".equals(want) || want.equals(section);
}
private static BigDecimal dec(final Object o) {
return o instanceof BigDecimal b ? b : BigDecimal.ZERO;
}
/**
* Savings, fixed and recurring deposit accounts. All three live in m_savings_account, told
* apart by deposit_type_enum: 100 savings, 200 fixed, 300 recurring.
*
* Accounts submitted after the date are excluded — on that date they did not exist, and listing
* them would make a register of a past day describe a present one.
*/
private List<Map<String, Object>> depositAccounts(final LocalDate asOn, final String want) {
final Date date = Date.valueOf(asOn);
final StringBuilder typeFilter = new StringBuilder();
if (!"ALL".equals(want)) {
final int type = switch (want) {
case "SAVINGS" -> 100;
case "FD" -> 200;
case "RD" -> 300;
default -> 0;
};
if (type > 0) {
typeFilter.append(" AND sa.deposit_type_enum = ").append(type);
}
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sa.id, sa.account_no, sa.deposit_type_enum, sa.status_enum,
sa.activatedon_date, sa.closedon_date, sa.submittedon_date,
sa.account_balance_derived,
c.display_name AS client_name,
p.name AS product_name,
(SELECT t.running_balance_derived
FROM m_savings_account_transaction t
WHERE t.savings_account_id = sa.id
AND COALESCE(t.is_reversed, false) = false
AND t.transaction_date <= ?
ORDER BY t.transaction_date DESC, t.id DESC
LIMIT 1) AS balance_on_date
FROM m_savings_account sa
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_savings_product p ON p.id = sa.product_id
WHERE (sa.submittedon_date IS NULL OR sa.submittedon_date <= ?)
""" + typeFilter + """
ORDER BY sa.deposit_type_enum, sa.account_no
""", date, date);
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> m = new LinkedHashMap<>();
final int depositType = num(r.get("deposit_type_enum"));
m.put("accountId", r.get("id"));
m.put("accountNo", r.get("account_no"));
m.put("section", depositType == 200 ? "FD" : depositType == 300 ? "RD" : "SAVINGS");
m.put("clientName", r.get("client_name"));
m.put("productName", r.get("product_name"));
m.put("status", savingsStatus(num(r.get("status_enum"))));
m.put("openedOn", str(r.get("activatedon_date")));
m.put("closedOn", str(r.get("closedon_date")));
m.put("openedOnDate", sameDay(r.get("activatedon_date"), asOn));
m.put("closedOnDate", sameDay(r.get("closedon_date"), asOn));
// No transaction up to that date means the account had nothing in it yet, not that the
// balance is unknown.
m.put("balanceOnDate", r.get("balance_on_date") == null ? BigDecimal.ZERO : r.get("balance_on_date"));
m.put("currentBalance", r.get("account_balance_derived") == null
? BigDecimal.ZERO : r.get("account_balance_derived"));
out.add(m);
}
return out;
}
/**
* Share accounts. Their transactions carry no running balance the way savings ones do, but they
* are individually dated, so the holding on a given date is the shares purchased up to that
* date less those redeemed — which is a real dated figure, not the current one relabelled.
*/
private List<Map<String, Object>> shareAccounts(final LocalDate asOn) {
final Date date = Date.valueOf(asOn);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sa.id, sa.account_no, sa.status_enum, sa.activated_date, sa.closed_date,
sa.total_approved_shares,
c.display_name AS client_name,
p.name AS product_name,
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
ELSE 0 END), 0)
FROM m_share_account_transactions t
WHERE t.account_id = sa.id AND t.is_active = true
AND t.status_enum = 300
AND t.type_enum IN (500, 600)) AS share_value,
-- Purchases less redemptions dated on or before the register's date. Charge
-- rows (type 700) are excluded: an entry fee is not share capital. Only
-- approved purchases count (status 300) — a rejected application leaves its
-- row behind still flagged active, and counting it credited a member with
-- shares they were never issued.
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares * t.unit_price
WHEN t.type_enum = 600 THEN -(t.total_shares * t.unit_price)
ELSE 0 END), 0)
FROM m_share_account_transactions t
WHERE t.account_id = sa.id AND t.is_active = true
AND t.status_enum = 300
AND t.type_enum IN (500, 600)
AND t.transaction_date <= ?) AS value_on_date,
(SELECT COALESCE(SUM(CASE WHEN t.type_enum = 500 THEN t.total_shares
WHEN t.type_enum = 600 THEN -t.total_shares
ELSE 0 END), 0)
FROM m_share_account_transactions t
WHERE t.account_id = sa.id AND t.is_active = true
AND t.status_enum = 300
AND t.type_enum IN (500, 600)
AND t.transaction_date <= ?) AS shares_on_date
FROM m_share_account sa
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_share_product p ON p.id = sa.product_id
WHERE (sa.submitted_date IS NULL OR sa.submitted_date <= ?)
ORDER BY sa.account_no
""", date, date, date);
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("accountId", r.get("id"));
m.put("accountNo", r.get("account_no"));
m.put("section", "SHARE");
m.put("clientName", r.get("client_name"));
m.put("productName", r.get("product_name"));
m.put("status", shareStatus(num(r.get("status_enum"))));
m.put("openedOn", str(r.get("activated_date")));
m.put("closedOn", str(r.get("closed_date")));
m.put("openedOnDate", sameDay(r.get("activated_date"), asOn));
m.put("closedOnDate", sameDay(r.get("closed_date"), asOn));
m.put("balanceOnDate", r.get("value_on_date") == null ? BigDecimal.ZERO : r.get("value_on_date"));
m.put("currentBalance", r.get("share_value") == null ? BigDecimal.ZERO : r.get("share_value"));
// Shares as at the date, not the approved figure — an account can hold fewer than were
// approved once some have been redeemed.
m.put("sharesHeld", r.get("shares_on_date"));
out.add(m);
}
return out;
}
private static boolean sameDay(final Object dbDate, final LocalDate asOn) {
return dbDate != null && asOn.toString().equals(dbDate.toString());
}
private static String str(final Object o) {
return o == null ? null : o.toString();
}
private static int num(final Object o) {
return o instanceof Number n ? n.intValue() : 0;
}
private static String savingsStatus(final int status) {
return switch (status) {
case 100 -> "SUBMITTED";
case 200 -> "APPROVED";
case 300 -> "ACTIVE";
case 400 -> "WITHDRAWN";
case 500 -> "REJECTED";
case 600 -> "CLOSED";
case 700 -> "TRANSFER IN PROGRESS";
case 800 -> "TRANSFER ON HOLD";
case 900 -> "PREMATURELY CLOSED";
default -> "UNKNOWN";
};
}
private static String shareStatus(final int status) {
return switch (status) {
case 100 -> "SUBMITTED";
case 200 -> "APPROVED";
case 300 -> "ACTIVE";
case 400 -> "WITHDRAWN";
case 500 -> "REJECTED";
case 600 -> "CLOSED";
default -> "UNKNOWN";
};
}
}
@@ -30,6 +30,7 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
@@ -42,7 +43,9 @@ import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
// REQUIRES_NEW for the same reason as AmlRuleEvaluationServiceImpl: a failed alert write must not
// be able to mark the caller's transfer transaction rollback-only and undo a settled transfer.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public class AmlFundTransferEvaluationService {
private static final int STRUCTURING_WINDOW_DAYS = 30;
@@ -31,11 +31,20 @@ import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* REQUIRES_NEW rather than the default REQUIRED: AML monitoring is a passive observer of a
* transaction that has already succeeded. Joining the caller's transaction would let a failed
* alert insert mark it rollback-only, destroying the customer's deposit/withdrawal/transfer at
* commit time - and the callers' catch blocks cannot rescue that, because the damage is done to
* the shared transaction rather than thrown to them. A private transaction keeps AML failure
* contained to AML.
*/
@Service
@RequiredArgsConstructor
@Transactional
@Transactional(propagation = Propagation.REQUIRES_NEW)
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private static final int MEDIUM_RISK = 2;
@@ -0,0 +1,477 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.branchday.api;
import com.google.gson.Gson;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Branch day register — the formal opening and closing of a branch's business day.
*
* A day that was never closed is the thing this exists to surface: while it stays open its balance
* cannot be carried forward reliably, a back-dated entry can still be posted into it, and any cash
* difference on that date goes unnoticed. The register records who opened and closed each day, and
* lists the ones still hanging.
*
* Closing a day late is allowed but never silent: it is stamped as a force-close with the reason it
* was left open, so "closed that evening" and "closed nine days later" stay distinguishable.
*/
@Path("/v1/branchday")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class BranchDayRegisterApiResource {
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final FromJsonHelper fromJsonHelper;
private final Gson gson = new Gson();
// ── GET /v1/branchday ── the register itself ──────────────────────────────
/**
* The register for one office, newest first. Defaults to the caller's own office so a branch
* user sees their own branch without having to know its id.
*/
@GET
public String list(@QueryParam("officeId") final Long officeIdParam, @QueryParam("limit") final Integer limitParam) {
final AppUser currentUser = this.context.authenticatedUser();
final Long officeId = resolveOffice(officeIdParam, currentUser);
final int limit = limitParam == null || limitParam < 1 ? 60 : Math.min(limitParam, 400);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
r.opened_by_name, r.opened_at, r.opening_cash,
r.closed_by_name, r.closed_at, r.closing_cash,
r.book_cash, r.cash_difference,
r.force_closed, r.force_close_reason, r.remarks
FROM m_branch_day_register r
LEFT JOIN m_office o ON o.id = r.office_id
WHERE r.office_id = ?
ORDER BY r.business_date DESC
LIMIT ?
""", officeId, limit);
final Map<String, Object> result = new LinkedHashMap<>();
result.put("officeId", officeId);
result.put("canOperate", canOperateDay(currentUser));
result.put("days", mapDays(rows));
result.put("today", LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString());
result.put("openDay", findOpenDay(officeId));
return gson.toJson(result);
}
// ── GET /v1/branchday/missed ── days left open ────────────────────────────
/**
* Business days that were opened and never closed, oldest first — the ones that need attention
* most. Today's own open day is excluded: it is open because the day is still running, which is
* not a missed close.
*/
@GET
@Path("missed")
public String missed(@QueryParam("officeId") final Long officeIdParam) {
final AppUser currentUser = this.context.authenticatedUser();
final Long officeId = resolveOffice(officeIdParam, currentUser);
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT r.id, r.office_id, o.name AS office_name, r.business_date,
r.opened_by_name, r.opened_at, r.opening_cash,
r.closed_by_name, r.closed_at, r.closing_cash,
r.book_cash, r.cash_difference,
r.force_closed, r.force_close_reason, r.remarks
FROM m_branch_day_register r
LEFT JOIN m_office o ON o.id = r.office_id
WHERE r.office_id = ? AND r.closed_at IS NULL AND r.business_date < ?
ORDER BY r.business_date ASC
""", officeId, Date.valueOf(today));
final List<Map<String, Object>> days = mapDays(rows);
// Age in days is what makes the list actionable — a day open since last week is a different
// problem from one open since yesterday.
for (final Map<String, Object> d : days) {
final Object bd = d.get("businessDate");
d.put("daysOpen", bd == null ? null : java.time.temporal.ChronoUnit.DAYS.between(LocalDate.parse(bd.toString()), today));
}
return gson.toJson(Map.of("officeId", officeId, "canOperate", canOperateDay(currentUser), "missed", days,
"count", days.size()));
}
// ── GET /v1/branchday/close-preview ──────────────────────────────────────
/**
* What the day should close at, with the arithmetic that produces it, plus the denomination
* total the cashier actually keyed in during the day.
*
* Exists so nobody has to work the figure out by hand or type it from memory: the screen shows
* where every line came from, and offers the counted total as the closing figure. A day is
* still closed on what someone physically counted — this only removes the guesswork about what
* that count is supposed to match.
*/
@GET
@Path("close-preview")
public String closePreview(@QueryParam("officeId") final Long officeIdParam,
@QueryParam("businessDate") final String businessDateParam) {
final AppUser currentUser = this.context.authenticatedUser();
final Long officeId = resolveOffice(officeIdParam, currentUser);
final LocalDate businessDate;
if (businessDateParam != null && !businessDateParam.isBlank()) {
try {
businessDate = LocalDate.parse(businessDateParam);
} catch (final java.time.format.DateTimeParseException e) {
throw new PlatformDataIntegrityException("error.msg.branchday.date.invalid",
"Business date must be in yyyy-MM-dd format.");
}
} else {
final Map<String, Object> open = findOpenDay(officeId);
if (open == null) {
throw new PlatformDataIntegrityException("error.msg.branchday.not.open",
"No business day is open for this branch.");
}
businessDate = LocalDate.parse(String.valueOf(open.get("businessDate")));
}
final Map<String, Object> result = new LinkedHashMap<>(bookBreakdown(officeId, businessDate));
result.put("officeId", officeId);
result.put("businessDate", businessDate.toString());
// What the cashier's own denomination entries add up to for the day. Where this and the
// expected figure agree, the day tallies and the closing figure is not in question.
final BigDecimal countedIn = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination
WHERE txn_date = ? AND txn_type = 'DEPOSIT'
""", BigDecimal.class, Date.valueOf(businessDate)));
final BigDecimal countedOut = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(total_amount), 0) FROM m_savings_txn_denomination
WHERE txn_date = ? AND txn_type = 'WITHDRAWAL'
""", BigDecimal.class, Date.valueOf(businessDate)));
result.put("denominationIn", countedIn);
result.put("denominationOut", countedOut);
return gson.toJson(result);
}
// ── POST /v1/branchday/open ───────────────────────────────────────────────
@POST
@Path("open")
@Consumes(MediaType.APPLICATION_JSON)
public String openDay(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
requireDayAuthority(currentUser);
final var root = this.fromJsonHelper.parse(body);
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
final LocalDate businessDate = extractDate(root, "businessDate",
LocalDate.now(DateUtils.getDateTimeZoneOfTenant()));
final BigDecimal openingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("openingCash", root);
// A branch runs one day at a time. Opening a second while the first is still open is how a
// day gets stranded, so the previous one has to be closed first.
final Map<String, Object> open = findOpenDay(officeId);
if (open != null) {
final Object existing = open.get("businessDate");
if (businessDate.toString().equals(String.valueOf(existing))) {
throw new PlatformDataIntegrityException("error.msg.branchday.already.open",
"Business day " + businessDate + " is already open for this branch.");
}
throw new PlatformDataIntegrityException("error.msg.branchday.previous.open",
"Business day " + existing + " is still open. Close it before opening " + businessDate + ".");
}
final Integer exists = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
Integer.class, officeId, Date.valueOf(businessDate));
if (exists != null && exists > 0) {
throw new PlatformDataIntegrityException("error.msg.branchday.already.closed",
"Business day " + businessDate + " has already been opened and closed for this branch.");
}
// Yesterday's closing is today's opening unless the caller states otherwise, so the carried
// figure does not have to be retyped and cannot drift.
final BigDecimal carried = openingCash != null ? openingCash : lastClosingCash(officeId, businessDate);
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_branch_day_register
(office_id, business_date, opened_by, opened_by_name, opened_at, opening_cash, force_closed)
VALUES (?,?,?,?,?,?,false)
RETURNING id
""",
Long.class, officeId, Date.valueOf(businessDate), currentUser.getId(), currentUser.getDisplayName(),
Timestamp.from(Instant.now()), carried);
return gson.toJson(Map.of("resourceId", id, "officeId", officeId, "businessDate", businessDate.toString(),
"openingCash", carried == null ? BigDecimal.ZERO : carried, "status", "OPEN"));
}
// ── POST /v1/branchday/close ──────────────────────────────────────────────
@POST
@Path("close")
@Consumes(MediaType.APPLICATION_JSON)
public String closeDay(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
requireDayAuthority(currentUser);
final var root = this.fromJsonHelper.parse(body);
final Long officeId = resolveOffice(this.fromJsonHelper.extractLongNamed("officeId", root), currentUser);
final LocalDate businessDate = extractDate(root, "businessDate", null);
if (businessDate == null) {
throw new PlatformDataIntegrityException("error.msg.branchday.date.required",
"Which business day is being closed?");
}
final BigDecimal closingCash = this.fromJsonHelper.extractBigDecimalWithLocaleNamed("closingCash", root);
final String remarks = this.fromJsonHelper.extractStringNamed("remarks", root);
final String forceReason = this.fromJsonHelper.extractStringNamed("forceCloseReason", root);
final List<Map<String, Object>> found = jdbcTemplate.queryForList(
"SELECT id, closed_at, opening_cash FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
officeId, Date.valueOf(businessDate));
if (found.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.branchday.not.open",
"Business day " + businessDate + " was never opened for this branch.");
}
if (found.get(0).get("closed_at") != null) {
throw new PlatformDataIntegrityException("error.msg.branchday.already.closed",
"Business day " + businessDate + " is already closed.");
}
// Closing a past date is allowed — otherwise a day left open could never be settled at all —
// but it must say why it was left open, and it is stamped as a force close.
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final boolean isLate = businessDate.isBefore(today);
if (isLate && (forceReason == null || forceReason.isBlank())) {
throw new PlatformDataIntegrityException("error.msg.branchday.force.reason.required",
"Business day " + businessDate + " is being closed late. Record why it was left open.");
}
final BigDecimal bookCash = (BigDecimal) bookBreakdown(officeId, businessDate).get("expectedCash");
final BigDecimal difference = closingCash != null ? closingCash.subtract(bookCash) : null;
jdbcTemplate.update("""
UPDATE m_branch_day_register
SET closed_by = ?, closed_by_name = ?, closed_at = ?, closing_cash = ?,
book_cash = ?, cash_difference = ?, force_closed = ?, force_close_reason = ?, remarks = ?
WHERE office_id = ? AND business_date = ?
""",
currentUser.getId(), currentUser.getDisplayName(), Timestamp.from(Instant.now()), closingCash,
bookCash, difference, isLate, isLate ? forceReason : null, remarks,
officeId, Date.valueOf(businessDate));
final Map<String, Object> res = new LinkedHashMap<>();
res.put("officeId", officeId);
res.put("businessDate", businessDate.toString());
res.put("status", "CLOSED");
res.put("closingCash", closingCash);
res.put("bookCash", bookCash);
res.put("cashDifference", difference);
res.put("forceClosed", isLate);
return gson.toJson(res);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private List<Map<String, Object>> mapDays(final List<Map<String, Object>> rows) {
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : rows) {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("id", r.get("id"));
m.put("officeId", r.get("office_id"));
m.put("officeName", r.get("office_name"));
m.put("businessDate", str(r.get("business_date")));
m.put("openedByName", r.get("opened_by_name"));
m.put("openedAt", str(r.get("opened_at")));
m.put("openingCash", r.get("opening_cash"));
m.put("closedByName", r.get("closed_by_name"));
m.put("closedAt", str(r.get("closed_at")));
m.put("closingCash", r.get("closing_cash"));
m.put("bookCash", r.get("book_cash"));
m.put("cashDifference", r.get("cash_difference"));
m.put("forceClosed", r.get("force_closed"));
m.put("forceCloseReason", r.get("force_close_reason"));
m.put("remarks", r.get("remarks"));
m.put("status", r.get("closed_at") == null ? "OPEN" : "CLOSED");
out.add(m);
}
return out;
}
private static String str(final Object o) {
return o == null ? null : o.toString();
}
/** The branch's currently open day, or null when none is open. */
private Map<String, Object> findOpenDay(final Long officeId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, office_id, business_date, opened_by_name, opened_at, opening_cash
FROM m_branch_day_register
WHERE office_id = ? AND closed_at IS NULL
ORDER BY business_date ASC LIMIT 1
""", officeId);
if (rows.isEmpty()) {
return null;
}
final Map<String, Object> r = rows.get(0);
final Map<String, Object> m = new LinkedHashMap<>();
m.put("id", r.get("id"));
m.put("businessDate", str(r.get("business_date")));
m.put("openedByName", r.get("opened_by_name"));
m.put("openedAt", str(r.get("opened_at")));
m.put("openingCash", r.get("opening_cash"));
return m;
}
/** The most recent closing figure before this date, so it carries forward as the opening. */
private BigDecimal lastClosingCash(final Long officeId, final LocalDate before) {
final List<BigDecimal> rows = jdbcTemplate.query("""
SELECT closing_cash FROM m_branch_day_register
WHERE office_id = ? AND business_date < ? AND closing_cash IS NOT NULL
ORDER BY business_date DESC LIMIT 1
""", (rs, n) -> rs.getBigDecimal(1), officeId, Date.valueOf(before));
return rows.isEmpty() ? BigDecimal.ZERO : rows.get(0);
}
/**
* Cash the books say the branch should be holding at the end of that date: what came in as
* approved cash deposits less what went out as approved cash withdrawals. Deliberately derived
* here and then stored on the row, so the figure the day was closed against survives any later
* reversal.
*/
private Map<String, Object> bookBreakdown(final Long officeId, final LocalDate businessDate) {
final Date date = Date.valueOf(businessDate);
// The figure recorded when this very day was opened — not the previous day's closing.
// Deriving it from history alone ignored an opening balance keyed in by hand, which is
// exactly what happens on the first day a branch is put on the system: the day was opened
// with 20,000 in the drawer and the closing arithmetic started from zero.
final BigDecimal ownOpening = jdbcTemplate.query(
"SELECT opening_cash FROM m_branch_day_register WHERE office_id = ? AND business_date = ?",
(rs, n) -> rs.getBigDecimal(1), officeId, date).stream().findFirst().orElse(null);
final BigDecimal opening = ownOpening != null ? ownOpening : lastClosingCash(officeId, businessDate);
final BigDecimal deposits = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(amount), 0) FROM m_cashier_deposit_request
WHERE status = 'APPROVED'
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
AND (office_id = ? OR office_id IS NULL)
""", BigDecimal.class, date, officeId));
final BigDecimal withdrawals = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(amount), 0) FROM m_clerk_withdrawal_request
WHERE status = 'APPROVED'
AND COALESCE(txn_date, CAST(submitted_at AS DATE)) = ?
""", BigDecimal.class, date));
// Cash drawn from the vault to a cashier — more physical cash sitting in the branch.
final BigDecimal vaultIn = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(transfer_amount), 0) FROM m_cash_transfer
WHERE transfer_date = ? AND COALESCE(is_reversed, false) = false
""", BigDecimal.class, date));
// Cash a cashier settled back to the vault at the end of the day.
final BigDecimal settledOut = zero(jdbcTemplate.queryForObject("""
SELECT COALESCE(SUM(settlement_amount), 0) FROM m_cash_settlement
WHERE settlement_date = ? AND COALESCE(is_reversed, false) = false
""", BigDecimal.class, date));
final BigDecimal expected = opening.add(deposits).add(vaultIn).subtract(withdrawals).subtract(settledOut);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("openingCash", opening);
out.put("deposits", deposits);
out.put("vaultIn", vaultIn);
out.put("withdrawals", withdrawals);
out.put("settledOut", settledOut);
out.put("expectedCash", expected);
return out;
}
private static BigDecimal zero(final BigDecimal v) {
return v == null ? BigDecimal.ZERO : v;
}
private Long resolveOffice(final Long requested, final AppUser user) {
if (requested != null) {
return requested;
}
return user.getOffice() != null ? user.getOffice().getId() : 1L;
}
private LocalDate extractDate(final com.google.gson.JsonElement root, final String field, final LocalDate fallback) {
final String raw = this.fromJsonHelper.extractStringNamed(field, root);
if (raw == null || raw.isBlank()) {
return fallback;
}
try {
return LocalDate.parse(raw);
} catch (final java.time.format.DateTimeParseException e) {
throw new PlatformDataIntegrityException("error.msg.branchday.date.invalid",
"Business date must be in yyyy-MM-dd format.");
}
}
/**
* Who may open or close a branch day: a clerk, head cashier or manager. The clerk carries the
* same authority as the manager here by design — a day left open because the only manager was
* away is exactly the situation this register exists to prevent.
*
* A cashier is excluded: the cashier works inside the day and must not be the one who declares
* it closed.
*/
private boolean canOperateDay(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return true;
}
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier") || n.contains("clerk"));
}
private void requireDayAuthority(final AppUser user) {
if (!canOperateDay(user)) {
throw new PlatformDataIntegrityException("error.msg.branchday.forbidden",
"Only a Clerk, Head Cashier or Bank Manager can open or close a business day.");
}
}
}
@@ -32,6 +32,179 @@ public class CashReportApiResource {
private final CashierRoleHelper cashierRoleHelper;
private final Gson gson = new Gson();
/**
* Day book — every voucher raised on one date, in one list, newest first.
*
* Pulls the three registers that carry a voucher: cashier deposits (CR), clerk withdrawals (DR)
* and the clerk disbursement register (DR, or CR for a collection). Each row carries its
* voucher number, side, amount, account, party and status, so the cashier can reconcile a day
* without opening three separate screens.
*
* Dated on the business date the voucher belongs to rather than the row's creation timestamp:
* deposits and withdrawals carry an explicit txn_date, and the disbursement register — which
* has no such column — falls back to the date it was submitted on.
*/
@GET
@Path("day-book")
public String dayBook(@QueryParam("date") final String date, @QueryParam("side") final String side,
@QueryParam("status") final String status) {
final String txnDate = (date != null && !date.isBlank()) ? date : LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).toString();
final List<Map<String, Object>> rows = new ArrayList<>();
// Each register is left-joined to the verification table and to an attachment count, so a
// row already carries its physical-check verdict and whether a slip was scanned in — the
// screen never has to fire a follow-up call per voucher.
final String verifyJoin = """
LEFT JOIN m_voucher_verification vv ON vv.voucher_no = r.voucher_no
LEFT JOIN (SELECT voucher_no, COUNT(*) AS n FROM m_voucher_attachment GROUP BY voucher_no)
va ON va.voucher_no = r.voucher_no
""";
final String verifyCols = """
vv.status AS verify_status, vv.slip_amount, vv.remarks AS verify_remarks,
vv.verified_by_name, vv.verified_at,
vv.resolution_status, vv.resolution_notes, vv.resolved_by_name, vv.resolved_at,
COALESCE(va.n, 0) AS attachment_count
""";
// Cashier deposits — money in, so the credit side.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no, 'CR' AS side, 'DEPOSIT' AS kind, r.account_no, r.account_type,
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
r.cashier_name AS handled_by, r.manager_name AS approved_by, r.txn_id,
r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_cashier_deposit_request r
""" + verifyJoin + """
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Clerk withdrawals — money out, so the debit side.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no, 'DR' AS side, 'WITHDRAWAL' AS kind, r.account_no, r.account_type,
r.client_name AS party, r.amount, r.payment_type_name, r.description, r.status,
r.clerk_name AS handled_by, r.manager_name AS approved_by, r.tok_no AS txn_id,
r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_clerk_withdrawal_request r
""" + verifyJoin + """
WHERE COALESCE(r.txn_date, CAST(r.submitted_at AS DATE)) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Disbursement register: a collection pulls money into the GL (CR); a disbursement or a
// bank transfer moves it out (DR) — matching how the voucher itself was numbered.
rows.addAll(jdbcTemplate.query("""
SELECT r.voucher_no,
CASE WHEN r.request_type = 'COLLECTION' THEN 'CR' ELSE 'DR' END AS side,
r.request_type AS kind, r.gl_account_no AS account_no, 'GL' AS account_type,
r.gl_account_name AS party, r.dr_amount AS amount, r.payment_type_name,
r.description, r.status, r.clerk_name AS handled_by,
r.manager_name AS approved_by, NULL AS txn_id, r.submitted_at, r.actioned_at,
""" + verifyCols + """
FROM m_clerk_disbursement_request r
""" + verifyJoin + """
WHERE CAST(r.submitted_at AS DATE) = CAST(? AS DATE)
""", this::mapDayBookRow, txnDate));
// Filtering and ordering happen here rather than in SQL: three separate queries cannot be
// ordered as one result set, and the row count for a single day is small.
final String sideFilter = side == null || side.isBlank() || "ALL".equalsIgnoreCase(side) ? null : side.toUpperCase();
final String statusFilter = status == null || status.isBlank() || "ALL".equalsIgnoreCase(status) ? null : status.toUpperCase();
final List<Map<String, Object>> filtered = new ArrayList<>();
BigDecimal creditTotal = BigDecimal.ZERO;
BigDecimal debitTotal = BigDecimal.ZERO;
for (final Map<String, Object> row : rows) {
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
continue;
}
if (statusFilter != null && !statusFilter.equalsIgnoreCase((String) row.get("status"))) {
continue;
}
filtered.add(row);
// Only approved money is real money: a pending or rejected voucher must not inflate the
// day's totals, even though it still belongs in the listing.
if ("APPROVED".equalsIgnoreCase((String) row.get("status")) || "COMPLETED".equalsIgnoreCase((String) row.get("status"))) {
final BigDecimal amount = (BigDecimal) row.get("amount");
if (amount != null) {
if ("CR".equals(row.get("side"))) {
creditTotal = creditTotal.add(amount);
} else {
debitTotal = debitTotal.add(amount);
}
}
}
}
filtered.sort((a, b) -> {
final Object left = b.get("submittedAt");
final Object right = a.get("submittedAt");
if (left == null && right == null) {
return 0;
}
if (left == null) {
return -1;
}
if (right == null) {
return 1;
}
return left.toString().compareTo(right.toString());
});
final Map<String, Object> result = new LinkedHashMap<>();
result.put("date", txnDate);
result.put("rows", filtered);
result.put("count", filtered.size());
result.put("creditTotal", creditTotal);
result.put("debitTotal", debitTotal);
result.put("netTotal", creditTotal.subtract(debitTotal));
return gson.toJson(result);
}
private Map<String, Object> mapDayBookRow(final java.sql.ResultSet rs, final int rowNum) throws java.sql.SQLException {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("voucherNo", rs.getString("voucher_no"));
row.put("side", rs.getString("side"));
row.put("kind", rs.getString("kind"));
row.put("accountNo", rs.getString("account_no"));
row.put("accountType", rs.getString("account_type"));
row.put("party", rs.getString("party"));
row.put("amount", rs.getBigDecimal("amount"));
row.put("paymentTypeName", rs.getString("payment_type_name"));
row.put("description", rs.getString("description"));
row.put("status", rs.getString("status"));
row.put("handledBy", rs.getString("handled_by"));
row.put("approvedBy", rs.getString("approved_by"));
row.put("txnId", rs.getString("txn_id"));
// Physical-slip check. verifyStatus is null until someone actually looks at the paper.
row.put("verifyStatus", rs.getString("verify_status"));
row.put("slipAmount", rs.getBigDecimal("slip_amount"));
row.put("verifyRemarks", rs.getString("verify_remarks"));
row.put("verifiedByName", rs.getString("verified_by_name"));
final java.sql.Timestamp verifiedAt = rs.getTimestamp("verified_at");
row.put("verifiedAt", verifiedAt == null ? null : verifiedAt.toLocalDateTime().toString());
// How the exception ended, if it has. Without this the day book keeps showing a closed
// exception as though it were still open — the flag never clears on screen.
row.put("resolutionStatus", rs.getString("resolution_status"));
row.put("resolutionNotes", rs.getString("resolution_notes"));
row.put("resolvedByName", rs.getString("resolved_by_name"));
final java.sql.Timestamp resolvedAt = rs.getTimestamp("resolved_at");
row.put("resolvedAt", resolvedAt == null ? null : resolvedAt.toLocalDateTime().toString());
row.put("attachmentCount", rs.getInt("attachment_count"));
// Emitted as plain ISO strings: Gson serialises java.sql.Timestamp into a locale-shaped
// form the browser cannot parse reliably.
final java.sql.Timestamp submitted = rs.getTimestamp("submitted_at");
final java.sql.Timestamp actioned = rs.getTimestamp("actioned_at");
row.put("submittedAt", submitted == null ? null : submitted.toLocalDateTime().toString());
row.put("actionedAt", actioned == null ? null : actioned.toLocalDateTime().toString());
return row;
}
@GET
@Path("closing")
public String closing(@QueryParam("date") String date) {
@@ -0,0 +1,754 @@
package org.apache.fineract.portfolio.cashtransfer.api;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Share Advance Collection API — cash taken from a member BEFORE any share account exists.
*
* The cooperative's counter flow is: money first, share account afterwards. This resource covers both halves of that
* flow.
*
* <p>
* <b>1. Collect</b> — {@code POST /v1/shareadvancecollection}
*
* <pre>
* Dr Cash at Vault (SHARES_REFERENCE) = amount
* Cr Share Sundry (SHARES_SUSPENSE) = amount
* </pre>
*
* The money sits in the Share Sundry liability against a receipt number naming the member, so the unapplied rows in
* {@code m_share_advance_collection} always reconcile to the Sundry balance. No share account is involved yet.
*
* <p>
* <b>2. Apply</b> — {@code POST /v1/shareadvancecollection/{id}/apply}
*
* <pre>
* Dr Share Sundry (SHARES_SUSPENSE) = everything collected
* Cr Share Capital (SHARES_EQUITY) = share amount
* Cr Income from Fees (INCOME_FROM_FEES) = charge amount (only when a charge applies)
* Cr Savings (via deposit cmd) = over-payment (only when the member paid more)
* </pre>
*
* Applying clears the liability, issues the share capital, marks the account's charges paid and sets
* {@code collection_applied}, which is what reveals the purchase transaction in the passbook and transactions views.
* Account creation, approval and activation post no GL of their own — all share accounting happens here.
*
* <p>
* <b>When the cash does not match</b>
*
* <p>
* A member paying at the counter rarely hands over the exact figure, so neither case is treated as an error to be
* argued with at the window:
*
* <ul>
* <li><b>Short</b> — blocked. Share capital cannot be part-issued, so the clerk takes the balance on a further receipt
* and applies the receipts together: one settlement, several receipts, all marked applied.</li>
* <li><b>Over</b> — allowed, and the difference is credited to the member's linked savings account through Fineract's
* own deposit command, so it reaches their passbook rather than resting in a suspense GL that no record can attribute
* back to them.</li>
* </ul>
*/
@Path("/v1/shareadvancecollection")
@Component
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class ShareAdvanceCollectionApiResource {
// ── acc_gl_journal_entry.type_enum ─────────────────────────────────────
// Fineract convention: 1 = CREDIT (Cr), 2 = DEBIT (Dr).
private static final int DEBIT = 2;
private static final int CREDIT = 1;
// ── CashAccountsForShares financial_account_type ───────────────────────
private static final int FA_SHARES_REFERENCE = 1;
private static final int FA_SHARES_SUSPENSE = 2;
private static final int FA_INCOME_FROM_FEES = 3;
private static final int FA_SHARES_EQUITY = 4;
// ── acc_product_mapping.product_type for shares ────────────────────────
private static final int PRODUCT_TYPE_SHARES = 4;
// ── m_share_account.status_enum ───────────────────────────────────────
private static final int STATUS_ACTIVE = 300;
// ── GL account the cashier deposits share money into ──────────────────
private static final String SHARE_SUNDRY_ACCOUNT_NO = "SHASUN001";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new Gson();
// ══════════════════════════════════════════════════════════════════════
// POST — Take cash from a member with no share account yet
// ══════════════════════════════════════════════════════════════════════
@POST
@Transactional
public Response collect(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final Long clientId = getLong(json, "clientId");
final BigDecimal amount = getBigDecimal(json, "amount");
final String note = sanitiseNote(getString(json, "note"));
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
if (clientId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.clientid.required", "clientId is required.");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.invalid", "amount must be greater than zero.");
}
final Map<String, Object> client = loadClient(clientId);
final Long officeId = toLong(client.get("office_id"));
final String clientName = (String) client.get("display_name");
// The share product drives the GL mapping. Collection happens before an account exists,
// so the product is supplied by the caller or defaults to the only mapped share product.
final Long productId = resolveProductId(getLong(json, "productId"));
final String currencyCode = resolveCurrency(productId);
final Long glReference = requireGl(productId, FA_SHARES_REFERENCE, "Cash at Vault (Share Reference)");
final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHADV-" + System.nanoTime();
final String receiptNo = nextReceiptNo();
final String desc = "Share advance collected from " + clientName + " | Receipt " + receiptNo + (note != null ? " | " + note : "");
insertEntry(officeId, glReference, DEBIT, amount, txnId, entryDate, null, userId, now, currencyCode, desc);
insertEntry(officeId, glSuspense, CREDIT, amount, txnId, entryDate, null, userId, now, currencyCode,
"Share advance held in Sundry | Receipt " + receiptNo);
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, clientId, officeId, java.sql.Date.valueOf(entryDate), amount, currencyCode, txnId, userId,
currentUser.getUsername(), note);
final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_share_advance_collection WHERE receipt_no = ?", Long.class,
receiptNo);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("collectionId", id);
out.put("receiptNo", receiptNo);
out.put("clientId", clientId);
out.put("clientName", clientName);
out.put("amount", amount);
out.put("currency", currencyCode);
out.put("status", "UNAPPLIED");
out.put("glTransactionId", txnId);
out.put("message", "Cash received and held in Share Sundry. Quote receipt " + receiptNo + " when the share account is created.");
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — Unapplied receipts, optionally for one client
// ══════════════════════════════════════════════════════════════════════
@GET
public Response listUnapplied(@QueryParam("clientId") final Long clientId) {
this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id,"
+ " cl.display_name AS client_name, " + " c.collection_date, c.amount, c.currency_code, c.status, c.note "
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id " + " WHERE c.status = 'UNAPPLIED' ");
if (clientId != null) {
sql.append(" AND c.client_id = ").append(clientId);
}
sql.append(" ORDER BY c.collection_date, c.id");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString());
final Map<String, Object> out = new LinkedHashMap<>();
out.put("unappliedCollections", rows);
out.put("totalUnapplied", rows.stream().map(r -> (BigDecimal) r.get("amount")).reduce(BigDecimal.ZERO, BigDecimal::add));
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// GET — One unapplied receipt by the voucher number quoted by the member
// ══════════════════════════════════════════════════════════════════════
// The voucher arrives as a query parameter because numbers are formatted CR/001/2108/0001:
// as a path segment the encoded slashes (%2F) are rejected by Spring Security's strict
// firewall with a 400 before the request reaches this resource. The older
// /by-voucher/{voucherNo} path is kept for receipt numbers, which carry no slashes.
@GET
@Path("/by-voucher")
public Response findByVoucherParam(@QueryParam("voucherNo") final String voucherNo,
@QueryParam("clientId") final Long clientIdHint) {
if (voucherNo == null || voucherNo.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.required",
"A voucher or receipt number is required.");
}
return findByVoucher(voucherNo, clientIdHint);
}
@GET
@Path("/by-voucher/{voucherNo}")
public Response findByVoucher(@PathParam("voucherNo") final String voucherNo, @QueryParam("clientId") final Long clientIdHint) {
this.context.authenticatedUser();
// Matched on voucher_no or receipt_no so a pre-voucher receipt number still resolves.
final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note," + " c.applied_share_account_id "
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id "
+ " WHERE c.voucher_no = ? OR c.receipt_no = ? ORDER BY c.id DESC", voucherNo, voucherNo);
if (rows.isEmpty()) {
// Cash is normally taken through the cashier's Transaction Entry deposit into the Share
// Sundry GL, which lives in m_cashier_deposit_request. Adopt that approved deposit as a
// collection so the member's voucher resolves here.
final Map<String, Object> adopted = adoptApprovedDeposit(voucherNo, clientIdHint);
if (adopted != null) {
return Response.ok(gson.toJson(adopted)).build();
}
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.notfound", "No share collection found for voucher "
+ voucherNo + ". Check the number, or confirm the deposit has been approved by a manager.");
}
final Map<String, Object> row = rows.get(0);
if (!"UNAPPLIED".equals(row.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
"Voucher " + voucherNo + " is already " + row.get("status") + " and cannot be applied again.");
}
return Response.ok(gson.toJson(row)).build();
}
// ══════════════════════════════════════════════════════════════════════
// POST — Apply a receipt to an activated share account
// ══════════════════════════════════════════════════════════════════════
@POST
@Path("/{collectionId}/apply")
@Transactional
public Response apply(@PathParam("collectionId") final Long collectionId, final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
final JsonObject json = parseBody(body);
final Long shareAccountId = getLong(json, "shareAccountId");
if (shareAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.accountid.required", "shareAccountId is required.");
}
// A member who pays in instalments has more than one receipt against the same application:
// ₹4,500 today, the ₹500 balance tomorrow. Neither settles the account on its own, so they
// are applied together as one settlement. The path receipt leads; any others come in the
// body.
final List<Long> collectionIds = new java.util.ArrayList<>();
collectionIds.add(collectionId);
for (final Long additionalId : getLongList(json, "additionalCollectionIds")) {
if (!collectionIds.contains(additionalId)) {
collectionIds.add(additionalId);
}
}
final List<Map<String, Object>> collections = new java.util.ArrayList<>();
for (final Long id : collectionIds) {
final Map<String, Object> row = loadCollection(id);
if (!"UNAPPLIED".equals(row.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.already.applied",
"Receipt " + row.get("receipt_no") + " is already " + row.get("status") + ".");
}
collections.add(row);
}
final Map<String, Object> coll = collections.get(0);
final Map<String, Object> acct = loadAccount(shareAccountId);
// Every receipt belongs to the member: applying one to someone else's account would move
// one member's money into another's share capital. Checked per receipt, since a settlement
// can now carry several.
final Long acctClientId = toLong(acct.get("client_id"));
for (final Map<String, Object> row : collections) {
if (!toLong(row.get("client_id")).equals(acctClientId)) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.mismatch", "Receipt " + row.get("receipt_no")
+ " belongs to a different member than share account " + acct.get("account_no") + ".");
}
}
final int statusEnum = toInt(acct.get("status_enum"));
if (statusEnum != STATUS_ACTIVE) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.active",
"Share account " + acct.get("account_no") + " must be Active before a collection can be applied.");
}
if (Boolean.TRUE.equals(acct.get("collection_applied"))) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.already.settled",
"Share account " + acct.get("account_no") + " has already been settled.");
}
// Share value the account was opened for, and any entry fee attached to it.
final BigDecimal shareAmount = shareValueOf(shareAccountId);
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
final BigDecimal required = shareAmount.add(chargeAmount);
BigDecimal receiptAmount = BigDecimal.ZERO;
final StringBuilder receiptNos = new StringBuilder();
for (final Map<String, Object> row : collections) {
receiptAmount = receiptAmount.add((BigDecimal) row.get("amount"));
if (receiptNos.length() > 0) {
receiptNos.append(" + ");
}
receiptNos.append(row.get("receipt_no"));
}
// Short is blocked: share capital cannot be part-issued — the member either bought those
// shares or did not — so settling for less would leave the account short by an amount owed
// by nobody. The clerk collects the balance on another receipt and applies both together.
if (receiptAmount.compareTo(required) < 0) {
final BigDecimal shortfall = required.subtract(receiptAmount);
throw new PlatformDataIntegrityException("error.msg.shareadvance.amount.short",
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no") + " requires "
+ required + " (shares " + shareAmount + " + charges " + chargeAmount + "). Collect the remaining " + shortfall
+ " from the member, then apply both receipts together.");
}
// Over-payment is allowed through rather than turning a member away over a rounded-up note.
// The difference is credited to their linked savings account below.
final BigDecimal excessAmount = receiptAmount.subtract(required);
final Long savingsAccountId = toLong(acct.get("savings_account_id"));
if (excessAmount.compareTo(BigDecimal.ZERO) > 0 && savingsAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.excess.no.savings",
"Receipt " + receiptNos + " totals " + receiptAmount + " but share account " + acct.get("account_no")
+ " requires only " + required + ". The extra " + excessAmount + " must be credited to the member's savings "
+ "account, but none is linked to this share account. Link one, or collect the exact amount.");
}
final Long officeId = toLong(acct.get("office_id"));
final Long productId = toLong(acct.get("product_id"));
final String currencyCode = (String) acct.get("currency_code");
final Long glSuspense = requireGl(productId, FA_SHARES_SUSPENSE, "Share Sundry (Suspense)");
final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate entryDate = parseDate(getString(json, "transactionDate"));
final String txnId = "SHAPP-" + System.nanoTime();
final String accountNo = (String) acct.get("account_no");
// Dr Sundry for everything collected, including any over-payment — the liability those
// receipts created is cleared in full, in one leg, so nothing is left behind in suspense.
insertEntry(officeId, glSuspense, DEBIT, receiptAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share application settled from Sundry | Receipt " + receiptNos + " | " + accountNo);
// Cr Share Capital for the share value.
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share capital issued for " + accountNo);
// Cr Income from Fees for the entry fee, when one was charged.
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
// Resolved from the share product's "Income from Fees" mapping, which points at the
// Share Entry Fee Income GL — changing that mapping redirects the fee without a code change.
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share entry fee for " + accountNo);
jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
}
// The over-payment goes back to the member as a savings deposit rather than a bare journal
// entry, so it lands in their passbook and running balance the way any counter deposit
// would. Posted through Fineract's own deposit command, which writes its own Cr Savings
// Control leg — the Dr side is the Sundry debit above, which covered the whole receipt.
Long excessSavingsTransactionId = null;
if (excessAmount.compareTo(BigDecimal.ZERO) > 0) {
excessSavingsTransactionId = executeSavingsDeposit(savingsAccountId, excessAmount, entryDate,
"Excess on share application receipt " + receiptNos + " for share account " + accountNo);
}
// Stamp the originating cashier deposits so the same vouchers cannot be applied twice.
for (final Map<String, Object> row : collections) {
if (row.get("voucher_no") != null) {
jdbcTemplate.update(
"UPDATE m_cashier_deposit_request SET share_account_id = ?, share_applied_on = ?, "
+ " share_applied_txn_id = ? WHERE voucher_no = ? AND account_no = ? AND share_account_id IS NULL",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, row.get("voucher_no"), SHARE_SUNDRY_ACCOUNT_NO);
}
}
// Every receipt in this settlement is marked applied, not just the one in the path — they
// were all consumed by it, and an unmarked one would still show as money awaiting use.
for (final Long id : collectionIds) {
jdbcTemplate.update(
"UPDATE m_share_advance_collection SET status = 'APPLIED', applied_share_account_id = ?, "
+ " applied_on_date = ?, applied_gl_transaction_id = ? WHERE id = ?",
shareAccountId, java.sql.Date.valueOf(entryDate), txnId, id);
}
// Reveals the purchase transaction in the passbook and transactions views.
jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("collectionId", collectionId);
out.put("collectionIds", collectionIds);
out.put("receiptNo", receiptNos.toString());
out.put("shareAccountId", shareAccountId);
out.put("accountNo", accountNo);
out.put("shareAmount", shareAmount);
out.put("chargeAmount", chargeAmount);
out.put("totalApplied", required);
out.put("totalCollected", receiptAmount);
out.put("glTransactionId", txnId);
out.put("status", "APPLIED");
// Told at the counter, while the member is still there, rather than left to be noticed on
// a statement later.
out.put("excessAmount", excessAmount);
if (excessSavingsTransactionId != null) {
out.put("excessSavingsTransactionId", excessSavingsTransactionId);
out.put("excessMessage", "The member paid " + excessAmount + " more than this application required. It has been credited to "
+ "their savings account.");
}
return Response.ok(gson.toJson(out)).build();
}
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
/**
* Adopts an approved cashier deposit into the Share Sundry GL as a share advance collection.
*
* The cashier's Transaction Entry deposit already posted Dr Cash / Cr Share Sundry and was approved by a manager,
* so the money is genuinely in Sundry — it simply has no collection row yet. This creates that row on first lookup
* so the voucher resolves and can be applied.
*
* A deposit made straight to the Sundry GL carries no member, so the member is taken from the share account the
* clerk is settling ({@code clientIdHint}). The amount still has to match the account exactly at apply time, which
* is what keeps the money tied to the right member.
*
* @return the newly created collection row, or null when no approved Sundry deposit matches.
*/
private Map<String, Object> adoptApprovedDeposit(final String voucherNo, final Long clientIdHint) {
final List<Map<String, Object>> deposits = jdbcTemplate
.queryForList(
"SELECT d.id, d.voucher_no, d.client_id, d.amount, d.status, d.txn_date, d.txn_id,"
+ " d.office_id, d.currency_code, d.cashier_id, d.cashier_name, d.share_account_id"
+ " FROM m_cashier_deposit_request d"
+ " WHERE d.voucher_no = ? AND d.account_no = ? AND d.status = 'APPROVED'" + " ORDER BY d.id DESC",
voucherNo, SHARE_SUNDRY_ACCOUNT_NO);
if (deposits.isEmpty()) {
return null;
}
final Map<String, Object> dep = deposits.get(0);
if (toLong(dep.get("share_account_id")) != null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.already.applied",
"Voucher " + voucherNo + " has already been applied to a share account.");
}
// Prefer the member recorded on the deposit; fall back to the account being settled.
final Long clientId = dep.get("client_id") != null ? toLong(dep.get("client_id")) : clientIdHint;
if (clientId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.voucher.no.client",
"Voucher " + voucherNo + " was deposited straight to the Share Sundry GL without a member,"
+ " so it cannot be matched. Re-enter the deposit against the member.");
}
final Long officeId = dep.get("office_id") != null ? toLong(dep.get("office_id")) : loadClientOfficeId(clientId);
final String currency = dep.get("currency_code") != null ? (String) dep.get("currency_code") : "INR";
final LocalDate collectionDate = dep.get("txn_date") instanceof java.sql.Date ? ((java.sql.Date) dep.get("txn_date")).toLocalDate()
: LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final String receiptNo = nextReceiptNo();
jdbcTemplate.update(
"INSERT INTO m_share_advance_collection "
+ "(receipt_no, voucher_no, client_id, office_id, collection_date, amount, currency_code, status, "
+ " gl_transaction_id, collected_by_user, collected_by_name, note) " + "VALUES (?,?,?,?,?,?,?,'UNAPPLIED',?,?,?,?)",
receiptNo, voucherNo, clientId, officeId, java.sql.Date.valueOf(collectionDate), dep.get("amount"), currency,
dep.get("txn_id"), toLong(dep.get("cashier_id")), dep.get("cashier_name"),
"Adopted from cashier deposit voucher " + voucherNo);
return jdbcTemplate.queryForMap(
"SELECT c.id, c.receipt_no, c.voucher_no, c.client_id, cl.display_name AS client_name,"
+ " c.collection_date, c.amount, c.currency_code, c.status, c.note, c.applied_share_account_id"
+ " FROM m_share_advance_collection c JOIN m_client cl ON cl.id = c.client_id" + " WHERE c.receipt_no = ?",
receiptNo);
}
private Long loadClientOfficeId(final Long clientId) {
return jdbcTemplate.queryForObject("SELECT office_id FROM m_client WHERE id = ?", Long.class, clientId);
}
/** Share value the account was opened for: the pending/approved purchase transaction. */
private BigDecimal shareValueOf(final Long shareAccountId) {
final BigDecimal total = jdbcTemplate
.queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions "
+ " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId);
return total == null ? BigDecimal.ZERO : total;
}
/** Entry fee still outstanding on the account, zero when no charge was attached. */
private BigDecimal outstandingChargeOf(final Long shareAccountId) {
// Driven by the money still owed rather than the is_paid_derived flag alone: accounts
// created before the flat-charge fix carry the flag set to true while the full amount is
// still outstanding, and those charges must still be settled by the collection.
final BigDecimal total = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) " + " FROM m_share_account_charge "
+ " WHERE account_id = ? AND is_active = true " + " AND COALESCE(amount_outstanding_derived, 0) > 0",
BigDecimal.class, shareAccountId);
return total == null ? BigDecimal.ZERO : total;
}
private Map<String, Object> loadCollection(final Long id) {
try {
return jdbcTemplate.queryForMap("SELECT * FROM m_share_advance_collection WHERE id = ?", id);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.not.found",
"Share advance collection " + id + " does not exist.");
}
}
private Map<String, Object> loadClient(final Long clientId) {
try {
return jdbcTemplate.queryForMap("SELECT id, office_id, display_name FROM m_client WHERE id = ?", clientId);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.client.not.found", "Client " + clientId + " does not exist.");
}
}
private Map<String, Object> loadAccount(final Long shareAccountId) {
try {
return jdbcTemplate.queryForMap("SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, "
+ " sa.currency_code, sa.collection_applied, sa.savings_account_id, c.office_id "
+ " FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?", shareAccountId);
} catch (EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.account.not.found",
"Share account " + shareAccountId + " does not exist.");
}
}
/**
* Uses the caller's product when given, else the single mapped share product.
*
* The mapping table is joined to m_share_product because acc_product_mapping can hold rows for product ids that are
* not share products (stale rows left by deleted products), and counting those would make auto-detection fail even
* when only one real share product is mapped.
*/
private Long resolveProductId(final Long requested) {
if (requested != null) {
return requested;
}
final List<Long> ids = jdbcTemplate.queryForList("SELECT DISTINCT m.product_id FROM acc_product_mapping m "
+ " JOIN m_share_product p ON p.id = m.product_id WHERE m.product_type = ?", Long.class, PRODUCT_TYPE_SHARES);
if (ids.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.product.not.mapped",
"No share product has GL mappings configured.");
}
if (ids.size() > 1) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.productid.required",
"productId is required: " + ids.size() + " share products have GL mappings.");
}
return ids.get(0);
}
private String resolveCurrency(final Long productId) {
try {
final String code = jdbcTemplate.queryForObject("SELECT currency_code FROM m_share_product WHERE id = ?", String.class,
productId);
return code == null ? "INR" : code;
} catch (EmptyResultDataAccessException e) {
return "INR";
}
}
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
final Long glId = resolveGl(productId, financialAccountType);
if (glId == null) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.gl.not.mapped",
label + " GL is not mapped for share product " + productId + ".");
}
return glId;
}
private Long resolveGl(final Long productId, final int financialAccountType) {
try {
return jdbcTemplate.queryForObject(
"SELECT gl_account_id FROM acc_product_mapping "
+ " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
} catch (IncorrectResultSizeDataAccessException e) {
return null;
}
}
private String nextReceiptNo() {
final Long seq = jdbcTemplate.queryForObject("SELECT COALESCE(MAX(id), 0) + 1 FROM m_share_advance_collection", Long.class);
return String.format("SHADV%06d", seq == null ? 1 : seq);
}
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, "
+ " created_by, last_modified_by, created_date, lastmodified_date, "
+ " is_running_balance_calculated, office_running_balance, "
+ " organization_running_balance, submitted_on_date, transaction_date, " + " created_on_utc, last_modified_on_utc) "
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
}
/**
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
*
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
* 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
* the tenant's own zone first, which is the zone {@code now} was taken in.
*/
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
.withOffsetSameInstant(ZoneOffset.UTC);
}
private JsonObject parseBody(final String body) {
if (body == null || body.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.request.empty", "Request body is required.");
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (Exception e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.request.invalid", "Request body must be valid JSON.");
}
}
private LocalDate parseDate(final String raw) {
if (raw == null || raw.isBlank()) {
return LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
}
try {
return LocalDate.parse(raw);
} catch (DateTimeParseException e) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.date.invalid",
"transactionDate must be in YYYY-MM-DD format. Received: " + raw);
}
}
private String sanitiseNote(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
final String trimmed = raw.trim();
return trimmed.length() > 490 ? trimmed.substring(0, 490) : trimmed;
}
private String getString(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsString() : null;
}
private Long getLong(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null;
}
private BigDecimal getBigDecimal(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsBigDecimal() : null;
}
/** Further receipts making up one settlement. Empty when the caller sent a single receipt. */
private List<Long> getLongList(final JsonObject json, final String field) {
final List<Long> values = new java.util.ArrayList<>();
if (!json.has(field) || json.get(field).isJsonNull() || !json.get(field).isJsonArray()) {
return values;
}
for (final com.google.gson.JsonElement element : json.get(field).getAsJsonArray()) {
if (!element.isJsonNull()) {
values.add(element.getAsLong());
}
}
return values;
}
/**
* Credits an over-payment to the member's savings account.
*
* Routed through Fineract's own savings deposit command rather than a direct journal entry, so the money appears in
* the member's passbook, statement and running balance exactly as an over-the-counter deposit does. A bare GL entry
* would move the balance in the ledger while leaving the member's own account history silent about it.
*/
private Long executeSavingsDeposit(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate, final String note) {
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", txnDate.toString());
payload.addProperty("transactionAmount", amount);
payload.addProperty("paymentTypeId", resolveExcessPaymentTypeId());
payload.addProperty("note", note);
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString())
.build();
return this.commandsSourceWritePlatformService.logCommandSource(cmd).getResourceId();
}
/**
* The payment type this deposit is recorded under.
*
* Reuses the share settlement type seeded for the savings-debit route, since this is the same kind of movement in
* the opposite direction — an internal transfer between a share application and the member's savings, not cash
* over the counter. Falls back to any active type so an over-payment is never blocked by a missing seed row.
*/
private Long resolveExcessPaymentTypeId() {
final List<Long> byName = jdbcTemplate.queryForList(
"SELECT id FROM m_payment_type WHERE value = 'Share Application Settlement' ORDER BY id LIMIT 1", Long.class);
if (!byName.isEmpty()) {
return byName.get(0);
}
final List<Long> any = jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1", Long.class);
if (any.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.shareadvance.no.payment.type",
"No payment type is configured, so the over-payment cannot be credited to the member's savings account.");
}
return any.get(0);
}
private Long toLong(final Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
private int toInt(final Object o) {
return o instanceof Number ? ((Number) o).intValue() : 0;
}
}
@@ -375,8 +375,20 @@ public class ShareCollectionApiResource {
userId, userId, now, now,
java.sql.Date.valueOf(entryDate),
java.sql.Date.valueOf(entryDate),
now.atOffset(ZoneOffset.UTC),
now.atOffset(ZoneOffset.UTC));
toUtc(now),
toUtc(now));
}
/**
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
*
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
* 00:04 the next day and made an evening posting appear as just after midnight. The instant has to be resolved in
* the tenant's own zone first, which is the zone {@code now} was taken in.
*/
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
.withOffsetSameInstant(ZoneOffset.UTC);
}
private Map<String, Object> glEntryMap(final String account, final String type,
@@ -0,0 +1,601 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.cashtransfer.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Settles a share application out of the member's own savings account.
*
* The cash route ({@code ShareAdvanceCollectionApiResource}) starts at the counter: the cashier receives money, it parks
* in Share Sundry against a receipt, and applying that receipt turns it into share capital. A member who already banks
* with the society has no reason to withdraw cash and hand it straight back, so this settles the application directly:
*
* <pre>
* Dr Savings Control (share value + entry fee)
* Cr Share Capital (share value)
* Cr Entry Fee Income (charge, when one is attached)
* </pre>
*
* No Sundry leg, because nothing is ever held — the debit and the share issue happen together.
*
* Maker-checker, unlike the cash route: a clerk records the request and a manager approves it, and only the approval
* touches the member's balance. The cash route needs no checker because the cashier's receipt is already a second pair
* of eyes; here nobody has counted anything, so the approval is the first and only moment the member's money moves.
*
* The withdrawal itself goes through Fineract's own savings withdrawal command, so the member's passbook, statement and
* running balance reflect it exactly as an over-the-counter withdrawal would. That command posts its own
* Dr Savings Control / Cr Cash pair, which is wrong here — no cash left the branch — so those entries are reversed and
* replaced with the share-capital pair above.
*/
@Path("/v1/sharesavingsdebit")
@Component
@Tag(name = "Share Savings Debit", description = "Settle a share application by debiting the member's linked savings account, under maker-checker.")
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class ShareSavingsDebitRequestApiResource {
// Fineract convention: 1 = CREDIT, 2 = DEBIT.
private static final int CREDIT = 1;
private static final int DEBIT = 2;
// acc_product_mapping.financial_account_type for share products; mirrors ShareAdvanceCollectionApiResource.
private static final int FA_INCOME_FROM_FEES = 3;
private static final int FA_SHARES_EQUITY = 4;
private static final int PRODUCT_TYPE_SHARES = 4;
/** acc_product_mapping for savings: product_type 2, financial_account_type 2 = Savings Control. */
private static final int PRODUCT_TYPE_SAVINGS = 2;
private static final int FA_SAVINGS_CONTROL = 2;
private static final int SHARE_STATUS_ACTIVE = 300;
private static final int SAVINGS_STATUS_ACTIVE = 300;
/** m_payment_type.value seeded by changelog 0483 for this settlement. */
private static final String SETTLEMENT_PAYMENT_TYPE = "Share Application Settlement";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
// ── GET /v1/sharesavingsdebit/eligibility/{shareAccountId} ──────────────
/**
* Whether this share account can be settled from savings, and for how much.
*
* Answers before the clerk commits to anything: is the account active and unsettled, is a savings account linked,
* does it hold enough. A shortfall is reported here rather than only on submit, so the member can be told at the
* counter instead of after a request has been raised and has to be rejected.
*/
@GET
@Path("/eligibility/{shareAccountId}")
public Response eligibility(@PathParam("shareAccountId") final Long shareAccountId) {
this.context.authenticatedUser();
final Map<String, Object> acct = loadShareAccount(shareAccountId);
final BigDecimal shareAmount = shareValueOf(shareAccountId);
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
final BigDecimal required = shareAmount.add(chargeAmount);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("shareAccountId", shareAccountId);
out.put("shareAccountNo", acct.get("account_no"));
out.put("shareAmount", shareAmount);
out.put("chargeAmount", chargeAmount);
out.put("totalRequired", required);
final Object savingsAccountIdRaw = acct.get("savings_account_id");
if (savingsAccountIdRaw == null) {
out.put("eligible", false);
out.put("reason", "This share account has no linked savings account. Settle it by cash collection instead.");
return Response.ok(gson.toJson(out)).build();
}
final Long savingsAccountId = toLong(savingsAccountIdRaw);
final Map<String, Object> savings = loadSavingsAccount(savingsAccountId);
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
final BigDecimal available = balance == null ? BigDecimal.ZERO : balance;
out.put("savingsAccountId", savingsAccountId);
out.put("savingsAccountNo", savings.get("account_no"));
out.put("savingsBalance", available);
final String blocker = settlementBlocker(acct, savings, required, available);
out.put("eligible", blocker == null);
out.put("reason", blocker);
return Response.ok(gson.toJson(out)).build();
}
/**
* Why this share account cannot be settled from savings right now, or null when it can.
*
* Shared by eligibility and submit so the screen and the write path can never disagree about whether a settlement
* is allowed.
*/
private String settlementBlocker(final Map<String, Object> shareAccount, final Map<String, Object> savings,
final BigDecimal required, final BigDecimal available) {
if (toInt(shareAccount.get("status_enum")) != SHARE_STATUS_ACTIVE) {
return "Share account " + shareAccount.get("account_no") + " must be Active before it can be settled.";
}
if (Boolean.TRUE.equals(shareAccount.get("collection_applied"))) {
return "Share account " + shareAccount.get("account_no") + " has already been settled.";
}
if (required.compareTo(BigDecimal.ZERO) <= 0) {
return "There is nothing outstanding on share account " + shareAccount.get("account_no") + ".";
}
// The savings account must belong to the same member — debiting anyone else's balance for
// these shares would be taking one member's money for another's holding.
if (!toLong(shareAccount.get("client_id")).equals(toLong(savings.get("client_id")))) {
return "The linked savings account belongs to a different member.";
}
if (toInt(savings.get("status_enum")) != SAVINGS_STATUS_ACTIVE) {
return "Savings account " + savings.get("account_no") + " is not Active.";
}
if (available.compareTo(required) < 0) {
return "Savings account " + savings.get("account_no") + " holds " + available + " but " + required
+ " is required. Take a deposit first, or settle by cash collection.";
}
return null;
}
// ── POST /v1/sharesavingsdebit ── Clerk records the request ─────────────
@POST
@Transactional
public Response submit(final String body) {
final AppUser currentUser = this.context.authenticatedUser();
final JsonObject json = parseBody(body);
final Long shareAccountId = getLong(json, "shareAccountId");
if (shareAccountId == null) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.accountid.required", "shareAccountId is required.");
}
final Map<String, Object> acct = loadShareAccount(shareAccountId);
final Object savingsAccountIdRaw = acct.get("savings_account_id");
if (savingsAccountIdRaw == null) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.savings",
"Share account " + acct.get("account_no") + " has no linked savings account.");
}
final Long savingsAccountId = toLong(savingsAccountIdRaw);
final Map<String, Object> savings = loadSavingsAccount(savingsAccountId);
final BigDecimal shareAmount = shareValueOf(shareAccountId);
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
final BigDecimal required = shareAmount.add(chargeAmount);
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
// Checked at submit as well as at approval: the clerk should be told now, not after the
// member has left and a manager finds the account short.
final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance);
if (blocker != null) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker);
}
final Long existingPending = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_savings_debit_request WHERE share_account_id = ? AND status = 'PENDING'", Long.class,
shareAccountId);
if (existingPending != null && existingPending > 0) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.already.pending",
"A savings-debit request for this share account is already pending review.");
}
final Long newId = this.jdbcTemplate.queryForObject("""
INSERT INTO m_share_savings_debit_request
(share_account_id, client_id, savings_account_id, share_amount, charge_amount, total_amount,
currency_code, status, clerk_id, clerk_name, note)
VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
RETURNING id
""", Long.class, shareAccountId, toLong(acct.get("client_id")), savingsAccountId, shareAmount, chargeAmount, required,
acct.get("currency_code"), currentUser.getId(), currentUser.getDisplayName(), getString(json, "note"));
final Map<String, Object> out = new LinkedHashMap<>();
out.put("resourceId", newId);
out.put("status", "PENDING");
out.put("totalAmount", required);
return Response.ok(gson.toJson(out)).build();
}
// ── GET /v1/sharesavingsdebit ── Requests awaiting review ───────────────
@GET
public Response list(@QueryParam("status") @DefaultValue("PENDING") final String status,
@QueryParam("shareAccountId") final Long shareAccountId) {
this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("""
SELECT r.id, r.share_account_id, sa.account_no AS share_account_no, r.client_id, c.display_name AS client_name,
r.savings_account_id, sav.account_no AS savings_account_no, sav.account_balance_derived AS savings_balance,
r.share_amount, r.charge_amount, r.total_amount, r.currency_code, r.status,
r.clerk_id, r.clerk_name, r.submitted_at, r.manager_id, r.manager_name, r.actioned_at,
r.rejection_reason, r.savings_transaction_id, r.gl_transaction_id, r.note
FROM m_share_savings_debit_request r
JOIN m_share_account sa ON sa.id = r.share_account_id
JOIN m_client c ON c.id = r.client_id
LEFT JOIN m_savings_account sav ON sav.id = r.savings_account_id
WHERE 1 = 1
""");
final List<Object> params = new ArrayList<>();
if (!"ALL".equalsIgnoreCase(status)) {
sql.append(" AND r.status = ? ");
params.add(status.toUpperCase());
}
if (shareAccountId != null) {
sql.append(" AND r.share_account_id = ? ");
params.add(shareAccountId);
}
sql.append(" ORDER BY r.submitted_at DESC, r.id DESC");
return Response.ok(gson.toJson(this.jdbcTemplate.queryForList(sql.toString(), params.toArray()))).build();
}
// ── POST /v1/sharesavingsdebit/{id}/approve ── Manager approves ─────────
/**
* Debits the member's savings and issues the shares.
*
* Everything the submit checked is re-checked here against live figures: the balance may have moved since, and the
* approval is the point at which it actually matters.
*/
@POST
@Path("/{id}/approve")
@Transactional
public Response approve(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = this.context.authenticatedUser();
currentUser.validateHasPermissionTo("CREATE_JOURNALENTRY");
validateCheckerRole(currentUser);
final Map<String, Object> req = loadRequest(id);
if (!"PENDING".equals(req.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending",
"Only PENDING requests can be approved. This one is " + req.get("status") + ".");
}
if (toLong(req.get("clerk_id")).equals(currentUser.getId())) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.self.approval",
"Maker-checker: you cannot approve a request you submitted yourself. Ask another manager.");
}
final Long shareAccountId = toLong(req.get("share_account_id"));
final Long savingsAccountId = toLong(req.get("savings_account_id"));
final Map<String, Object> acct = loadShareAccount(shareAccountId);
final Map<String, Object> savings = loadSavingsAccount(savingsAccountId);
// Recomputed rather than taken from the request: shares or charges may have been edited
// between submit and approval, and the member must be debited for what is actually owed.
final BigDecimal shareAmount = shareValueOf(shareAccountId);
final BigDecimal chargeAmount = outstandingChargeOf(shareAccountId);
final BigDecimal required = shareAmount.add(chargeAmount);
final BigDecimal balance = (BigDecimal) savings.get("account_balance_derived");
final String blocker = settlementBlocker(acct, savings, required, balance == null ? BigDecimal.ZERO : balance);
if (blocker != null) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.eligible", blocker);
}
final Long productId = toLong(acct.get("product_id"));
final Long officeId = toLong(acct.get("office_id"));
final String currencyCode = (String) acct.get("currency_code");
final String accountNo = (String) acct.get("account_no");
final Long glEquity = requireGl(productId, FA_SHARES_EQUITY, "Share Capital (Equity)");
final Long glSavingsControl = savingsControlGlOf(savingsAccountId);
if (glSavingsControl == null) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.gl.not.mapped",
"The Savings Control GL is not mapped for this savings account's product.");
}
final LocalDate entryDate = DateUtils.getBusinessLocalDate();
final String note = "Share application settled from savings | " + accountNo;
// Fineract's own withdrawal command, so the member's passbook and statement show it. Its GL
// pair (Dr Savings Control / Cr Cash) is then reversed: no cash left the branch, the money
// became share capital, which the pair below records.
final Long savingsTxnId = executeSavingsWithdrawal(savingsAccountId, required, entryDate, note);
this.jdbcTemplate.update("UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?", "S" + savingsTxnId);
final long userId = currentUser.getId();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final String txnId = "SHSAV-" + savingsTxnId;
insertEntry(officeId, glSavingsControl, DEBIT, required, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share application debited from savings " + savings.get("account_no") + " | " + accountNo);
insertEntry(officeId, glEquity, CREDIT, shareAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share capital issued for " + accountNo);
if (chargeAmount.compareTo(BigDecimal.ZERO) > 0) {
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
insertEntry(officeId, glIncome, CREDIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share entry fee for " + accountNo);
this.jdbcTemplate.update("UPDATE m_share_account_charge SET is_paid_derived = true, amount_paid_derived = amount, "
+ " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
}
// Marks the account settled, which is what stops it being settled again by either route.
this.jdbcTemplate.update("UPDATE m_share_account SET collection_applied = true WHERE id = ?", shareAccountId);
this.jdbcTemplate.update("""
UPDATE m_share_savings_debit_request
SET status = 'APPROVED', manager_id = ?, manager_name = ?, actioned_at = now(),
share_amount = ?, charge_amount = ?, total_amount = ?,
savings_transaction_id = ?, gl_transaction_id = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), shareAmount, chargeAmount, required, savingsTxnId, txnId, id);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("resourceId", id);
out.put("status", "APPROVED");
out.put("shareAmount", shareAmount);
out.put("chargeAmount", chargeAmount);
out.put("totalDebited", required);
out.put("savingsTransactionId", savingsTxnId);
return Response.ok(gson.toJson(out)).build();
}
// ── POST /v1/sharesavingsdebit/{id}/reject ── Manager rejects ───────────
@POST
@Path("/{id}/reject")
@Transactional
public Response reject(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = this.context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> req = loadRequest(id);
if (!"PENDING".equals(req.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.not.pending",
"Only PENDING requests can be rejected. This one is " + req.get("status") + ".");
}
final JsonObject json = body == null || body.isBlank() ? new JsonObject() : parseBody(body);
this.jdbcTemplate.update("""
UPDATE m_share_savings_debit_request
SET status = 'REJECTED', manager_id = ?, manager_name = ?, actioned_at = now(), rejection_reason = ?
WHERE id = ? AND status = 'PENDING'
""", currentUser.getId(), currentUser.getDisplayName(), getString(json, "rejectionReason"), id);
final Map<String, Object> out = new LinkedHashMap<>();
out.put("resourceId", id);
out.put("status", "REJECTED");
return Response.ok(gson.toJson(out)).build();
}
// ── Helpers ────────────────────────────────────────────────────────────
private Long executeSavingsWithdrawal(final Long savingsAccountId, final BigDecimal amount, final LocalDate txnDate,
final String note) {
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "yyyy-MM-dd");
payload.addProperty("transactionDate", txnDate.toString());
payload.addProperty("transactionAmount", amount);
payload.addProperty("paymentTypeId", resolveSettlementPaymentTypeId());
payload.addProperty("note", note);
final CommandWrapper cmd = new CommandWrapperBuilder().savingsAccountWithdrawal(savingsAccountId).withJson(payload.toString())
.build();
final CommandProcessingResult result = this.commandsSourceWritePlatformService.logCommandSource(cmd);
return result.getResourceId();
}
/**
* The payment type this withdrawal is recorded under — a dedicated one seeded by changelog 0483, because the
* withdrawal is an internal book transfer into share capital rather than cash over the counter or a transfer out.
* The savings withdrawal command requires a payment type, and describing it as anything else would misrepresent it
* on the member's statement and in payment-type reporting.
*
* Falls back to the lowest-numbered payment type if the seed has not run, so an unmigrated tenant still settles
* rather than failing at approval with money already half-moved.
*/
private Long resolveSettlementPaymentTypeId() {
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT id FROM m_payment_type WHERE value = ? ORDER BY id LIMIT 1", SETTLEMENT_PAYMENT_TYPE);
if (!rows.isEmpty()) {
return toLong(rows.get(0).get("id"));
}
final List<Map<String, Object>> fallback = this.jdbcTemplate.queryForList("SELECT id FROM m_payment_type ORDER BY id LIMIT 1");
if (fallback.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.no.payment.type",
"No payment types are configured. Add one before settling a share application from savings.");
}
return toLong(fallback.get(0).get("id"));
}
/** Savings Control GL for this savings account's product. */
private Long savingsControlGlOf(final Long savingsAccountId) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT apm.gl_account_id FROM m_savings_account sa
JOIN acc_product_mapping apm ON apm.product_id = sa.product_id
WHERE sa.id = ? AND apm.product_type = ? AND apm.financial_account_type = ? LIMIT 1
""", savingsAccountId, PRODUCT_TYPE_SAVINGS, FA_SAVINGS_CONTROL);
return rows.isEmpty() ? null : toLong(rows.get(0).get("gl_account_id"));
}
/** Share value the account was opened for; mirrors ShareAdvanceCollectionApiResource. */
private BigDecimal shareValueOf(final Long shareAccountId) {
final BigDecimal total = this.jdbcTemplate
.queryForObject("SELECT COALESCE(SUM(total_shares * unit_price), 0) FROM m_share_account_transactions "
+ " WHERE account_id = ? AND is_active = true AND type_enum = 500", BigDecimal.class, shareAccountId);
return total == null ? BigDecimal.ZERO : total;
}
/** Entry fee still outstanding, zero when no charge was attached. */
private BigDecimal outstandingChargeOf(final Long shareAccountId) {
final BigDecimal total = this.jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(GREATEST(COALESCE(amount_outstanding_derived, 0), 0)), 0) FROM m_share_account_charge "
+ " WHERE account_id = ? AND is_active = true AND COALESCE(amount_outstanding_derived, 0) > 0",
BigDecimal.class, shareAccountId);
return total == null ? BigDecimal.ZERO : total;
}
private Map<String, Object> loadShareAccount(final Long shareAccountId) {
try {
return this.jdbcTemplate.queryForMap("""
SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.currency_code,
sa.collection_applied, sa.savings_account_id, c.office_id
FROM m_share_account sa JOIN m_client c ON c.id = sa.client_id WHERE sa.id = ?
""", shareAccountId);
} catch (final EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.account.not.found",
"Share account " + shareAccountId + " does not exist.");
}
}
private Map<String, Object> loadSavingsAccount(final Long savingsAccountId) {
try {
return this.jdbcTemplate.queryForMap(
"SELECT id, account_no, client_id, status_enum, account_balance_derived FROM m_savings_account WHERE id = ?",
savingsAccountId);
} catch (final EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.savings.not.found",
"Savings account " + savingsAccountId + " does not exist.");
}
}
private Map<String, Object> loadRequest(final Long id) {
try {
return this.jdbcTemplate.queryForMap("SELECT * FROM m_share_savings_debit_request WHERE id = ?", id);
} catch (final EmptyResultDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.not.found",
"Savings-debit request " + id + " does not exist.");
}
}
private Long requireGl(final Long productId, final int financialAccountType, final String label) {
try {
final Long glId = this.jdbcTemplate.queryForObject(
"SELECT gl_account_id FROM acc_product_mapping "
+ " WHERE product_id = ? AND product_type = ? AND financial_account_type = ? AND charge_id IS NULL",
Long.class, productId, PRODUCT_TYPE_SHARES, financialAccountType);
if (glId == null) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped",
label + " GL is not mapped for share product " + productId + ".");
}
return glId;
} catch (final IncorrectResultSizeDataAccessException e) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.gl.not.mapped",
label + " GL is not mapped for share product " + productId + ".");
}
}
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId,
final LocalDate entryDate, final Long shareAccountId, final long userId, final LocalDateTime now, final String currencyCode,
final String description) {
this.jdbcTemplate.update(
"INSERT INTO acc_gl_journal_entry " + "(account_id, office_id, currency_code, transaction_id, reversed, manual_entry, "
+ " entry_date, type_enum, amount, description, entity_id, "
+ " created_by, last_modified_by, created_date, lastmodified_date, "
+ " is_running_balance_calculated, office_running_balance, "
+ " organization_running_balance, submitted_on_date, transaction_date, created_on_utc, last_modified_on_utc) "
+ "VALUES (?,?,?,?,false,true,?,?,?,?,?,?,?,?,?,false,0,0,?,?,?,?)",
glAccountId, officeId, currencyCode, txnId, java.sql.Date.valueOf(entryDate), typeEnum, amount, description, shareAccountId,
userId, userId, now, now, java.sql.Date.valueOf(entryDate), java.sql.Date.valueOf(entryDate), toUtc(now), toUtc(now));
}
/**
* A tenant-local timestamp converted to the real UTC instant, for the {@code *_on_utc} columns.
*
* {@code atOffset(ZoneOffset.UTC)} would only relabel it — stamping 18:34 IST as "18:34 UTC", which reads back as
* 00:04 the next day and made an evening entry appear as midnight. The instant has to be resolved in the tenant's
* own zone first, which is the zone {@code now} was taken in.
*/
private java.time.OffsetDateTime toUtc(final LocalDateTime tenantLocalTime) {
return tenantLocalTime.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
.withOffsetSameInstant(ZoneOffset.UTC);
}
private void validateCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
if (!isChecker) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.checker.required",
"Only a Bank Manager (or Head Cashier) can approve or reject a savings-debit request.");
}
}
private JsonObject parseBody(final String body) {
if (body == null || body.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.empty", "Request body is required.");
}
try {
return JsonParser.parseString(body).getAsJsonObject();
} catch (final RuntimeException e) {
throw new PlatformDataIntegrityException("error.msg.sharesavingsdebit.request.invalid", "Request body is not valid JSON.");
}
}
private Long getLong(final JsonObject json, final String field) {
return json.has(field) && !json.get(field).isJsonNull() ? json.get(field).getAsLong() : null;
}
private String getString(final JsonObject json, final String field) {
if (!json.has(field) || json.get(field).isJsonNull()) {
return null;
}
final String value = json.get(field).getAsString().trim();
return value.isEmpty() ? null : value;
}
private Long toLong(final Object o) {
return o instanceof Number n ? n.longValue() : null;
}
private int toInt(final Object o) {
return o instanceof Number n ? n.intValue() : 0;
}
}
@@ -185,9 +185,13 @@ public class ClerkDisbursementRequestApiResource {
activityLog, Timestamp.from(Instant.now()), requestType
);
// A collection pulls money into the GL, so it reads as a credit; a disbursement and a
// bank transfer both move money out of the source side, so they read as a debit.
final String voucherPrefix = "COLLECTION".equals(requestType)
? VoucherService.PREFIX_CREDIT : VoucherService.PREFIX_DEBIT;
final String voucherNo = voucherService.createVoucher(
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
newId, txnAmount, currentUser);
newId, txnAmount, currentUser, voucherPrefix);
jdbcTemplate.update(
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
@@ -544,7 +548,7 @@ public class ClerkDisbursementRequestApiResource {
return ps;
}, btLogKh);
final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue();
final String btVoucherNo = generateBtVoucherNo(btLogId);
final String btVoucherNo = generateBtVoucherNo(btLogId, currentUser, finalBtTotal);
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId);
}
@@ -981,7 +985,7 @@ public class ClerkDisbursementRequestApiResource {
return ps;
}, logKh);
final long logId = Objects.requireNonNull(logKh.getKey()).longValue();
final String voucherNo = generateBtVoucherNo(logId);
final String voucherNo = generateBtVoucherNo(logId, currentUser, finalTotalDr);
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", voucherNo, logId);
final JsonObject res = new JsonObject();
@@ -997,9 +1001,15 @@ public class ClerkDisbursementRequestApiResource {
* Uses MAX(id) of the just-inserted row (passed in) to guarantee uniqueness even
* under concurrent inserts on the same date.
*/
private String generateBtVoucherNo(final long logId) {
return "BT" + LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("yyMMdd"))
+ String.format("%05d", logId);
/**
* Bank-transfer settlement voucher, drawn from the same central register as every other
* voucher so the two numbering schemes cannot drift apart. A settlement moves money out of
* the debited accounts, so it reads as a debit: DR/001/2108/0001.
*/
private String generateBtVoucherNo(final long logId, final AppUser currentUser, final BigDecimal amount) {
return voucherService.createVoucher(
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_BANK_TRANSFER_LOG,
logId, amount, currentUser, VoucherService.PREFIX_DEBIT);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -1,102 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.api;
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.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
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.springframework.stereotype.Component;
@Path("/v1/dds/batchruns")
@Component
@Tag(name = "DDS Batch Runs", description = "The Interest/Commission calculate-then-post batch: Generate previews a run's rows without changing any balance; Post applies exactly those previewed rows. A run can only be posted once.")
@RequiredArgsConstructor
public class BatchRunsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DDSBATCH";
private final PlatformSecurityContext context;
private final BatchRunReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Batch Runs", description = "Returns the list of DDS batch runs, most recent first.")
public Collection<BatchRunData> retrieveAll() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll();
}
@GET
@Path("{batchRunId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a DDS Batch Run", description = "Returns the details of a batch run, including its previewed/posted rows.")
public BatchRunData retrieveOne(@PathParam("batchRunId") @Parameter(description = "batchRunId") final Long batchRunId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveOne(batchRunId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Generate a DDS Batch Run", description = "Mandatory fields: jobType (INTEREST_CALCULATION, "
+ "OD_INTEREST_CALCULATION or COMMISSION_CALCULATION), branchScope (All or Selected), jobDate, agentFrom, agentTo\n"
+ "\n" + "Optional fields: partyFrom, partyTo (interest jobs only)")
public CommandProcessingResult generate(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.generateDdsBatchRun() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@POST
@Path("{batchRunId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Post a DDS Batch Run", description = "Applies exactly the rows a prior Generate call previewed: credits "
+ "each row's amount to the Party (interest jobs) or to the Agent's running commission total (commission jobs). "
+ "Fails if the run has already been posted.")
public CommandProcessingResult post(@PathParam("batchRunId") @Parameter(description = "batchRunId") final Long batchRunId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.postDdsBatchRun(batchRunId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -1,46 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "DDSBATCH", action = "GENERATE")
public class GenerateBatchRunCommandHandler implements NewCommandSourceHandler {
private final BatchRunWritePlatformService writePlatformService;
@Autowired
public GenerateBatchRunCommandHandler(final BatchRunWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.generateBatchRun(command);
}
}
@@ -1,46 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "DDSBATCH", action = "POST")
public class PostBatchRunCommandHandler implements NewCommandSourceHandler {
private final BatchRunWritePlatformService writePlatformService;
@Autowired
public PostBatchRunCommandHandler(final BatchRunWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.postBatchRun(command.entityId(), command);
}
}
@@ -1,106 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.serialization;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public final class BatchRunCommandFromApiJsonDeserializer {
public static final Set<String> SUPPORTED_JOB_TYPES = new HashSet<>(
Arrays.asList("INTEREST_CALCULATION", "OD_INTEREST_CALCULATION", "COMMISSION_CALCULATION"));
private static final Set<String> SUPPORTED_BRANCH_SCOPES = new HashSet<>(Arrays.asList("All", "Selected"));
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("jobType", "branchScope", "officeNames",
"jobDate", "agentFrom", "agentTo", "partyFrom", "partyTo", "dateFormat", "locale"));
private final FromJsonHelper fromApiJsonHelper;
@Autowired
public BatchRunCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForGenerate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("ddsbatchrun");
final JsonElement element = this.fromApiJsonHelper.parse(json);
v.reset().parameter("jobType").value(this.fromApiJsonHelper.extractStringNamed("jobType", element)).notBlank()
.isOneOfTheseStringValues(SUPPORTED_JOB_TYPES.toArray());
final String branchScope = this.fromApiJsonHelper.extractStringNamed("branchScope", element);
v.reset().parameter("branchScope").value(branchScope).notBlank().isOneOfTheseStringValues(SUPPORTED_BRANCH_SCOPES.toArray());
if ("Selected".equals(branchScope)) {
v.reset().parameter("officeNames").value(this.fromApiJsonHelper.extractJsonArrayNamed("officeNames", element))
.jsonArrayNotEmpty();
}
v.reset().parameter("jobDate").value(this.fromApiJsonHelper.extractLocalDateNamed("jobDate", element)).notNull();
v.reset().parameter("agentFrom").value(this.fromApiJsonHelper.extractLongNamed("agentFrom", element)).notNull()
.integerGreaterThanZero();
v.reset().parameter("agentTo").value(this.fromApiJsonHelper.extractLongNamed("agentTo", element)).notNull()
.integerGreaterThanZero();
if (this.fromApiJsonHelper.parameterExists("partyFrom", element)) {
v.reset().parameter("partyFrom").value(this.fromApiJsonHelper.extractLongNamed("partyFrom", element)).ignoreIfNull()
.integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("partyTo", element)) {
v.reset().parameter("partyTo").value(this.fromApiJsonHelper.extractLongNamed("partyTo", element)).ignoreIfNull()
.integerGreaterThanZero();
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -1,29 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.service;
import java.util.Collection;
import org.apache.fineract.portfolio.dds.batch.data.BatchRunData;
public interface BatchRunReadPlatformService {
Collection<BatchRunData> retrieveAll();
BatchRunData retrieveOne(Long batchRunId);
}
@@ -1,137 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.portfolio.dds.batch.data.BatchRunData;
import org.apache.fineract.portfolio.dds.batch.data.BatchRunRowData;
import org.apache.fineract.portfolio.dds.batch.exception.BatchRunNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@RequiredArgsConstructor
public class BatchRunReadPlatformServiceImpl implements BatchRunReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final BatchRunMapper BATCH_RUN_MAPPER = new BatchRunMapper();
private static final BatchRunRowMapper ROW_MAPPER = new BatchRunRowMapper();
private static final class BatchRunMapper implements RowMapper<BatchRunData> {
public String schema() {
return " b.id as id, b.job_type as jobType, b.branch_scope as branchScope, b.job_date as jobDate, "
+ " b.agent_from as agentFrom, b.agent_to as agentTo, b.party_from as partyFrom, "
+ " b.party_to as partyTo, b.status as status, b.total_rows as totalRows, b.total_amount as totalAmount, "
+ " b.generated_on_date as generatedOnDate, b.posted_on_date as postedOnDate from m_dds_batch_run b";
}
@Override
public BatchRunData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return BatchRunData.builder() //
.id(rs.getLong("id")) //
.jobType(rs.getString("jobType")) //
.branchScope(rs.getString("branchScope")) //
.jobDate(JdbcSupport.getLocalDate(rs, "jobDate")) //
.agentFrom(rs.getLong("agentFrom")) //
.agentTo(rs.getLong("agentTo")) //
.partyFrom(JdbcSupport.getLong(rs, "partyFrom")) //
.partyTo(JdbcSupport.getLong(rs, "partyTo")) //
.status(rs.getString("status")) //
.totalRows(rs.getInt("totalRows")) //
.totalAmount(rs.getBigDecimal("totalAmount")) //
.generatedOnDate(JdbcSupport.getLocalDate(rs, "generatedOnDate")) //
.postedOnDate(JdbcSupport.getLocalDate(rs, "postedOnDate")) //
.rows(new ArrayList<>()) //
.build();
}
}
private static final class BatchRunRowMapper implements RowMapper<Map.Entry<Long, BatchRunRowData>> {
public String schema() {
return " r.batch_run_id as batchRunId, r.id as id, r.party_id as partyId, p.party_code as partyCode, "
+ " p.party_name as partyName, r.agent_id as agentId, a.agent_code as agentCode, "
+ " r.amount as amount from m_dds_batch_run_row r " + " left join m_dds_party p on p.id = r.party_id "
+ " join m_dds_agent a on a.id = r.agent_id";
}
@Override
public Map.Entry<Long, BatchRunRowData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long batchRunId = rs.getLong("batchRunId");
final BatchRunRowData data = BatchRunRowData.builder() //
.id(rs.getLong("id")) //
.partyId(JdbcSupport.getLong(rs, "partyId")) //
.partyCode(rs.getString("partyCode")) //
.partyName(rs.getString("partyName")) //
.agentId(rs.getLong("agentId")) //
.agentCode(rs.getString("agentCode")) //
.amount(rs.getBigDecimal("amount")) //
.build();
return Map.entry(batchRunId, data);
}
}
@Override
public Collection<BatchRunData> retrieveAll() {
final String sql = "select " + BATCH_RUN_MAPPER.schema() + " order by b.id desc";
final List<BatchRunData> batchRuns = this.jdbcTemplate.query(sql, BATCH_RUN_MAPPER); // NOSONAR
return attachRows(batchRuns);
}
@Override
public BatchRunData retrieveOne(final Long batchRunId) {
try {
final String sql = "select " + BATCH_RUN_MAPPER.schema() + " where b.id = ?";
final BatchRunData batchRun = this.jdbcTemplate.queryForObject(sql, BATCH_RUN_MAPPER, batchRunId); // NOSONAR
return attachRows(List.of(batchRun)).iterator().next();
} catch (final EmptyResultDataAccessException e) {
throw new BatchRunNotFoundException(batchRunId);
}
}
private Collection<BatchRunData> attachRows(final List<BatchRunData> batchRuns) {
if (batchRuns.isEmpty()) {
return batchRuns;
}
final String sql = "select " + ROW_MAPPER.schema() + " order by r.id";
final List<Map.Entry<Long, BatchRunRowData>> rows = this.jdbcTemplate.query(sql, ROW_MAPPER); // NOSONAR
final Map<Long, List<BatchRunRowData>> rowsByBatchRunId = new LinkedHashMap<>();
for (final Map.Entry<Long, BatchRunRowData> row : rows) {
rowsByBatchRunId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
}
final List<BatchRunData> result = new ArrayList<>(batchRuns.size());
for (final BatchRunData b : batchRuns) {
result.add(b.toBuilder().rows(rowsByBatchRunId.getOrDefault(b.getId(), new ArrayList<>())).build());
}
return result;
}
}
@@ -1,29 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface BatchRunWritePlatformService {
CommandProcessingResult generateBatchRun(JsonCommand command);
CommandProcessingResult postBatchRun(Long batchRunId, JsonCommand command);
}
@@ -1,227 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.service;
import com.google.gson.JsonArray;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
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.commissionslab.domain.CommissionSlab;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
import org.apache.fineract.portfolio.dds.batch.domain.BatchRun;
import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRepository;
import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRow;
import org.apache.fineract.portfolio.dds.batch.exception.BatchRunAlreadyPostedException;
import org.apache.fineract.portfolio.dds.batch.exception.BatchRunNotFoundException;
import org.apache.fineract.portfolio.dds.batch.serialization.BatchRunCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.dds.party.domain.Party;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransaction;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWritePlatformService {
private static final BigDecimal DAYS_IN_YEAR = BigDecimal.valueOf(365);
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
private final BatchRunCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final BatchRunRepository batchRunRepository;
private final PartyRepository partyRepository;
private final AgentRepository agentRepository;
private final PartyTransactionRepository partyTransactionRepository;
private final CommissionSlabRepository commissionSlabRepository;
@Transactional
@Override
public CommandProcessingResult generateBatchRun(final JsonCommand command) {
this.fromApiJsonDeserializer.validateForGenerate(command.json());
final String jobType = command.stringValueOfParameterNamed("jobType");
final String branchScope = command.stringValueOfParameterNamed("branchScope");
final LocalDate jobDate = command.localDateValueOfParameterNamed("jobDate");
final Long agentFrom = command.longValueOfParameterNamed("agentFrom");
final Long agentTo = command.longValueOfParameterNamed("agentTo");
final Long partyFrom = command.parameterExists("partyFrom") ? command.longValueOfParameterNamed("partyFrom") : 1L;
final Long partyTo = command.parameterExists("partyTo") ? command.longValueOfParameterNamed("partyTo") : Long.MAX_VALUE;
final Set<String> officeNames = "Selected".equals(branchScope) ? extractOfficeNames(command) : null;
final BatchRun batchRun = new BatchRun(jobType, branchScope, jobDate, agentFrom, agentTo, partyFrom, partyTo, LocalDate.now());
switch (jobType) {
case "INTEREST_CALCULATION" -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, false,
officeNames);
case "OD_INTEREST_CALCULATION" -> generateInterestRows(batchRun, agentFrom, agentTo, partyFrom, partyTo, jobDate, true,
officeNames);
case "COMMISSION_CALCULATION" -> generateCommissionRows(batchRun, agentFrom, agentTo, jobDate, officeNames);
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
}
this.batchRunRepository.saveAndFlush(batchRun);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(batchRun.getId()) //
.build();
}
/** 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 same free-text branch concept the legacy screen itself used. */
private Set<String> extractOfficeNames(final JsonCommand command) {
if (!command.parameterExists("officeNames")) {
return null;
}
final JsonArray officeNamesArray = command.arrayOfParameterNamed("officeNames");
final Set<String> officeNames = new HashSet<>();
for (int i = 0; i < officeNamesArray.size(); i++) {
officeNames.add(officeNamesArray.get(i).getAsString().trim().toUpperCase());
}
return officeNames;
}
private boolean matchesSelectedBranch(final String homeBranch, final Set<String> officeNames) {
if (officeNames == null || officeNames.isEmpty()) {
return true;
}
return homeBranch != null && officeNames.contains(homeBranch.trim().toUpperCase());
}
private void generateInterestRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final Long partyFrom,
final Long partyTo, final LocalDate jobDate, final boolean odOnly, final Set<String> officeNames) {
final List<Party> parties = odOnly
? this.partyRepository.findForOdInterestBatch(agentFrom, agentTo, partyFrom, partyTo)
: this.partyRepository.findForInterestBatch(agentFrom, agentTo, partyFrom, partyTo);
for (final Party party : parties) {
if (!matchesSelectedBranch(party.getAgent().getHomeBranch(), officeNames)) {
continue;
}
final BigDecimal rate = party.getInterestRate();
final BigDecimal balance = party.getCurrentBalance();
if (rate == null || rate.compareTo(BigDecimal.ZERO) <= 0 || balance.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
final LocalDate accrueFrom = party.getInterestDate() != null ? party.getInterestDate() : party.getOpeningDate();
final long days = ChronoUnit.DAYS.between(accrueFrom, jobDate);
if (days <= 0) {
continue;
}
final BigDecimal amount = balance.multiply(rate).multiply(BigDecimal.valueOf(days))
.divide(HUNDRED.multiply(DAYS_IN_YEAR), 6, RoundingMode.HALF_UP).setScale(2, RoundingMode.HALF_UP);
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
batchRun.addRow(new BatchRunRow(party.getId(), party.getAgent().getId(), amount));
}
}
private void generateCommissionRows(final BatchRun batchRun, final Long agentFrom, final Long agentTo, final LocalDate jobDate,
final Set<String> officeNames) {
final List<Agent> agents = this.agentRepository.findBetweenIds(agentFrom, agentTo);
for (final Agent agent : agents) {
if (!matchesSelectedBranch(agent.getHomeBranch(), officeNames)) {
continue;
}
final LocalDate fromDate = agent.getLastCommissionDate() != null ? agent.getLastCommissionDate().plusDays(1)
: agent.getAgentOpeningDate();
if (fromDate.isAfter(jobDate)) {
continue;
}
final BigDecimal collectionVolume = this.partyTransactionRepository.sumPartyTranCredits(agent.getId(), fromDate, jobDate);
if (collectionVolume == null || collectionVolume.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
final List<CommissionSlab> matchingSlabs = this.commissionSlabRepository.findMatchingSlabs("ACCOUNT", collectionVolume);
if (matchingSlabs.isEmpty()) {
continue;
}
final BigDecimal commissionPercent = matchingSlabs.get(0).getCommissionPercent();
final BigDecimal amount = collectionVolume.multiply(commissionPercent).divide(HUNDRED, 2, RoundingMode.HALF_UP);
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
batchRun.addRow(new BatchRunRow(null, agent.getId(), amount));
}
}
@Transactional
@Override
public CommandProcessingResult postBatchRun(final Long batchRunId, final JsonCommand command) {
final BatchRun batchRun = this.batchRunRepository.findById(batchRunId).orElseThrow(() -> new BatchRunNotFoundException(batchRunId));
if (batchRun.isPosted()) {
throw new BatchRunAlreadyPostedException(batchRunId);
}
final boolean isCommissionJob = "COMMISSION_CALCULATION".equals(batchRun.getJobType());
final String transactionType = "OD_INTEREST_CALCULATION".equals(batchRun.getJobType()) ? "OD_INTEREST_POST" : "INTEREST_POST";
for (final BatchRunRow row : batchRun.getRows()) {
if (isCommissionJob) {
final Agent agent = this.agentRepository.findById(row.getAgentId())
.orElseThrow(() -> new AgentNotFoundException(row.getAgentId()));
agent.postCommission(row.getAmount(), batchRun.getJobDate());
} else {
final Party party = this.partyRepository.findById(row.getPartyId())
.orElseThrow(() -> new PartyNotFoundException(row.getPartyId()));
final Agent agent = party.getAgent();
final BigDecimal newBalance = party.credit(row.getAmount());
party.markInterestCalculatedThrough(batchRun.getJobDate());
final PartyTransaction transaction = new PartyTransaction(party, agent, batchRun.getJobDate(), transactionType,
"CREDIT", null, null, null, row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(), newBalance);
this.partyTransactionRepository.save(transaction);
}
}
batchRun.markPosted(LocalDate.now());
this.batchRunRepository.saveAndFlush(batchRun);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(batchRunId) //
.build();
}
}
@@ -1,53 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.batch.starter;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.portfolio.dds.batch.domain.BatchRunRepository;
import org.apache.fineract.portfolio.dds.batch.serialization.BatchRunCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformService;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformServiceImpl;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.apache.fineract.portfolio.dds.transaction.domain.PartyTransactionRepository;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class DdsBatchConfiguration {
@Bean
@ConditionalOnMissingBean(BatchRunReadPlatformService.class)
public BatchRunReadPlatformService batchRunReadPlatformService(JdbcTemplate jdbcTemplate) {
return new BatchRunReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(BatchRunWritePlatformService.class)
public BatchRunWritePlatformService batchRunWritePlatformService(BatchRunCommandFromApiJsonDeserializer fromApiJsonDeserializer,
BatchRunRepository batchRunRepository, PartyRepository partyRepository, AgentRepository agentRepository,
PartyTransactionRepository partyTransactionRepository, CommissionSlabRepository commissionSlabRepository) {
return new BatchRunWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, batchRunRepository, partyRepository,
agentRepository, partyTransactionRepository, commissionSlabRepository);
}
}
@@ -1,128 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.api;
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.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.party.data.PartyData;
import org.apache.fineract.portfolio.dds.party.service.PartyReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/parties")
@Component
@Tag(name = "DDS Parties", description = "A DDS Party is a customer's daily-deposit scheme account, linked to the Agent who collects for it. Carries the scheme's KYC, terms, two-stage overdue flags and a nominee list.")
@RequiredArgsConstructor
public class PartiesApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "PARTY";
private final PlatformSecurityContext context;
private final PartyReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Parties", description = "Returns the list of DDS parties.")
public Collection<PartyData> retrieveAll() {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll();
}
@GET
@Path("{partyId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a DDS Party", description = "Returns the details of a DDS party, including its nominees.")
public PartyData retrieveOne(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveOne(partyId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Create a DDS Party", description = "Mandatory fields: clientId, agentId, subType, partyName, address1, "
+ "city, pincode, mobileNoSms, operation, adultMinor, openingDate, dailyDepositAmount, days\n" + "\n"
+ "Optional fields: lastAccount, nominal, aadharNo, partyName1, address2, address3, gender, panCardNo, telephone, "
+ "birthDate, occupation, risk, ckycId, minorDate, guardianClientId, minAccountOpt, relation, month, maturityDate, "
+ "reopenDate, depositAmount, ledgerNo, withdrawType, lien, payDate, paymentAmount, openingBalance, isSmsActive, "
+ "isEmailActive, interestDate, spDescription, freezeCode, advCode, creditToBranch, creditToAccountType, "
+ "creditToAccount, nomineeApplicable, nomineeSplitMode, nominees, savingsAccountNo (links this party to an existing, "
+ "non-closed real savings account of the same client, resolved by account number - omit if the client has none)")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createParty() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@PUT
@Path("{partyId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Update a DDS Party", description = "Updates the details of a DDS party.")
public CommandProcessingResult update(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.updateParty(partyId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
@POST
@Path("{partyId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Close a DDS Party", description = "command=close is the only supported command. Mandatory field: "
+ "closeDate. The party's balance must already be zero (settled with a full Withdraw Deposite payout) before it "
+ "can be closed.")
public CommandProcessingResult handleCommand(@PathParam("partyId") @Parameter(description = "partyId") final Long partyId,
@QueryParam("command") @Parameter(description = "command") final String commandParam,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (!"close".equals(commandParam)) {
throw new UnrecognizedQueryParamException("command", commandParam);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.closeParty(partyId) //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -1,42 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface PartyRepository extends JpaRepository<Party, Long> {
@Query("select coalesce(max(cast(substring(p.partyCode, 3) as long)), 0) from Party p where p.partyCode like 'PT%'")
Long retrieveMaxPartyCodeSuffix();
Optional<Party> findByPartyCode(String partyCode);
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo")
List<Party> findForInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo);
@Query("select p from Party p where p.agent.id between :agentFrom and :agentTo and p.id between :partyFrom and :partyTo "
+ "and (p.od1Flag = true or p.od2Flag = true)")
List<Party> findForOdInterestBatch(@Param("agentFrom") Long agentFrom, @Param("agentTo") Long agentTo,
@Param("partyFrom") Long partyFrom, @Param("partyTo") Long partyTo);
}
@@ -1,46 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "PARTY", action = "CLOSE")
public class ClosePartyCommandHandler implements NewCommandSourceHandler {
private final PartyWritePlatformService writePlatformService;
@Autowired
public ClosePartyCommandHandler(final PartyWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.closeParty(command.entityId(), command);
}
}
@@ -1,46 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "PARTY", action = "CREATE")
public class CreatePartyCommandHandler implements NewCommandSourceHandler {
private final PartyWritePlatformService writePlatformService;
@Autowired
public CreatePartyCommandHandler(final PartyWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createParty(command);
}
}
@@ -1,46 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "PARTY", action = "UPDATE")
public class UpdatePartyCommandHandler implements NewCommandSourceHandler {
private final PartyWritePlatformService writePlatformService;
@Autowired
public UpdatePartyCommandHandler(final PartyWritePlatformService writePlatformService) {
this.writePlatformService = writePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.updateParty(command.entityId(), command);
}
}
@@ -1,294 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.serialization;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public final class PartyCommandFromApiJsonDeserializer {
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("clientId", "agentId", "subType",
"lastAccount", "nominal", "aadharNo", "partyName", "partyName1", "address1", "address2", "address3", "city", "pincode",
"gender", "panCardNo", "telephone", "mobileNoSms", "operation", "birthDate", "occupation", "risk", "ckycId", "adultMinor",
"minorDate", "guardianClientId", "minAccountOpt", "relation", "openingDate", "month", "maturityDate", "reopenDate",
"depositAmount", "dailyDepositAmount", "days", "ledgerNo", "interestRate", "withdrawType", "lien", "payDate",
"paymentAmount", "openingBalance", "isSmsActive", "isEmailActive", "interestDate", "spDescription", "freezeCode", "advCode",
"creditToBranch", "creditToAccountType", "creditToAccount", "nomineeApplicable", "nomineeSplitMode", "nominees",
"savingsAccountNo", "dateFormat", "locale"));
private static final Set<String> SUPPORTED_NOMINEE_PARAMETERS = new HashSet<>(
Arrays.asList("nominationDate", "name", "mobileNo", "birthDate", "minor", "relation", "gender", "guardianName"));
private final FromJsonHelper fromApiJsonHelper;
@Autowired
public PartyCommandFromApiJsonDeserializer(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
}
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party");
final JsonElement element = this.fromApiJsonHelper.parse(json);
v.reset().parameter("clientId").value(this.fromApiJsonHelper.extractLongNamed("clientId", element)).notNull()
.integerGreaterThanZero();
v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull()
.integerGreaterThanZero();
v.reset().parameter("subType").value(this.fromApiJsonHelper.extractStringNamed("subType", element)).notBlank()
.notExceedingLengthOf(60);
v.reset().parameter("partyName").value(this.fromApiJsonHelper.extractStringNamed("partyName", element)).notBlank()
.notExceedingLengthOf(100);
v.reset().parameter("address1").value(this.fromApiJsonHelper.extractStringNamed("address1", element)).notBlank()
.notExceedingLengthOf(200);
v.reset().parameter("city").value(this.fromApiJsonHelper.extractStringNamed("city", element)).notBlank()
.notExceedingLengthOf(100);
v.reset().parameter("pincode").value(this.fromApiJsonHelper.extractStringNamed("pincode", element)).notBlank()
.notExceedingLengthOf(20);
v.reset().parameter("mobileNoSms").value(this.fromApiJsonHelper.extractStringNamed("mobileNoSms", element)).notBlank()
.notExceedingLengthOf(20);
v.reset().parameter("operation").value(this.fromApiJsonHelper.extractStringNamed("operation", element)).notBlank()
.notExceedingLengthOf(20);
v.reset().parameter("adultMinor").value(this.fromApiJsonHelper.extractStringNamed("adultMinor", element)).notBlank()
.notExceedingLengthOf(20);
v.reset().parameter("openingDate").value(this.fromApiJsonHelper.extractLocalDateNamed("openingDate", element)).notNull();
v.reset().parameter("dailyDepositAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("dailyDepositAmount", element))
.notNull().positiveAmount();
v.reset().parameter("days").value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("days", element)).notNull()
.integerGreaterThanZero();
validateOptionalFields(element, v);
if (this.fromApiJsonHelper.parameterExists("nominees", element)) {
validateNominees(element.getAsJsonObject(), dataValidationErrors);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
public void validateForUpdate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SUPPORTED_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party");
final JsonElement element = this.fromApiJsonHelper.parse(json);
if (this.fromApiJsonHelper.parameterExists("clientId", element)) {
v.reset().parameter("clientId").value(this.fromApiJsonHelper.extractLongNamed("clientId", element)).notNull()
.integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("agentId", element)) {
v.reset().parameter("agentId").value(this.fromApiJsonHelper.extractLongNamed("agentId", element)).notNull()
.integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("subType", element)) {
v.reset().parameter("subType").value(this.fromApiJsonHelper.extractStringNamed("subType", element)).notBlank()
.notExceedingLengthOf(60);
}
if (this.fromApiJsonHelper.parameterExists("partyName", element)) {
v.reset().parameter("partyName").value(this.fromApiJsonHelper.extractStringNamed("partyName", element)).notBlank()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists("address1", element)) {
v.reset().parameter("address1").value(this.fromApiJsonHelper.extractStringNamed("address1", element)).notBlank()
.notExceedingLengthOf(200);
}
if (this.fromApiJsonHelper.parameterExists("city", element)) {
v.reset().parameter("city").value(this.fromApiJsonHelper.extractStringNamed("city", element)).notBlank()
.notExceedingLengthOf(100);
}
if (this.fromApiJsonHelper.parameterExists("pincode", element)) {
v.reset().parameter("pincode").value(this.fromApiJsonHelper.extractStringNamed("pincode", element)).notBlank()
.notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists("mobileNoSms", element)) {
v.reset().parameter("mobileNoSms").value(this.fromApiJsonHelper.extractStringNamed("mobileNoSms", element)).notBlank()
.notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists("operation", element)) {
v.reset().parameter("operation").value(this.fromApiJsonHelper.extractStringNamed("operation", element)).notBlank()
.notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists("adultMinor", element)) {
v.reset().parameter("adultMinor").value(this.fromApiJsonHelper.extractStringNamed("adultMinor", element)).notBlank()
.notExceedingLengthOf(20);
}
if (this.fromApiJsonHelper.parameterExists("openingDate", element)) {
v.reset().parameter("openingDate").value(this.fromApiJsonHelper.extractLocalDateNamed("openingDate", element)).notNull();
}
if (this.fromApiJsonHelper.parameterExists("dailyDepositAmount", element)) {
v.reset().parameter("dailyDepositAmount")
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("dailyDepositAmount", element)).notNull()
.positiveAmount();
}
if (this.fromApiJsonHelper.parameterExists("days", element)) {
v.reset().parameter("days").value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("days", element)).notNull()
.integerGreaterThanZero();
}
validateOptionalFields(element, v);
if (this.fromApiJsonHelper.parameterExists("nominees", element)) {
validateNominees(element.getAsJsonObject(), dataValidationErrors);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
public void validateForClose(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {
}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, Set.of("closeDate", "dateFormat", "locale"));
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party");
final JsonElement element = this.fromApiJsonHelper.parse(json);
v.reset().parameter("closeDate").value(this.fromApiJsonHelper.extractLocalDateNamed("closeDate", element)).notNull();
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private void validateOptionalFields(final JsonElement element, final DataValidatorBuilder v) {
optionalString(element, v, "lastAccount", 50);
optionalString(element, v, "aadharNo", 20);
optionalString(element, v, "partyName1", 100);
optionalString(element, v, "address2", 200);
optionalString(element, v, "address3", 200);
optionalString(element, v, "gender", 20);
optionalString(element, v, "panCardNo", 20);
optionalString(element, v, "telephone", 20);
optionalString(element, v, "occupation", 40);
optionalString(element, v, "risk", 20);
optionalString(element, v, "ckycId", 40);
optionalString(element, v, "minAccountOpt", 40);
optionalString(element, v, "relation", 40);
optionalString(element, v, "ledgerNo", 40);
optionalString(element, v, "withdrawType", 20);
optionalString(element, v, "spDescription", 500);
optionalString(element, v, "freezeCode", 20);
optionalString(element, v, "advCode", 40);
optionalString(element, v, "creditToBranch", 100);
optionalString(element, v, "creditToAccountType", 100);
optionalString(element, v, "creditToAccount", 50);
optionalString(element, v, "nomineeSplitMode", 20);
optionalString(element, v, "savingsAccountNo", 20);
if (this.fromApiJsonHelper.parameterExists("guardianClientId", element)) {
v.reset().parameter("guardianClientId").value(this.fromApiJsonHelper.extractLongNamed("guardianClientId", element))
.ignoreIfNull().integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("month", element)) {
v.reset().parameter("month").value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("month", element)).ignoreIfNull()
.integerGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists("depositAmount", element)) {
v.reset().parameter("depositAmount").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("depositAmount", element))
.ignoreIfNull().zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists("paymentAmount", element)) {
v.reset().parameter("paymentAmount")
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("paymentAmount", element)).ignoreIfNull()
.zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists("openingBalance", element)) {
v.reset().parameter("openingBalance")
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("openingBalance", element)).ignoreIfNull()
.zeroOrPositiveAmount();
}
if (this.fromApiJsonHelper.parameterExists("interestRate", element)) {
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
.ignoreIfNull().zeroOrPositiveAmount();
}
}
private void optionalString(final JsonElement element, final DataValidatorBuilder v, final String paramName, final int maxLength) {
if (this.fromApiJsonHelper.parameterExists(paramName, element)) {
v.reset().parameter(paramName).value(this.fromApiJsonHelper.extractStringNamed(paramName, element)).ignoreIfNull()
.notExceedingLengthOf(maxLength);
}
}
private void validateNominees(final JsonObject topLevelJson, final List<ApiParameterError> dataValidationErrors) {
final DataValidatorBuilder v = new DataValidatorBuilder(dataValidationErrors).resource("party.nominee");
final var nomineesArray = topLevelJson.getAsJsonArray("nominees");
for (int i = 0; i < nomineesArray.size(); i++) {
final JsonObject nomineeJson = nomineesArray.get(i).getAsJsonObject();
this.fromApiJsonHelper.checkForUnsupportedParameters(nomineeJson, SUPPORTED_NOMINEE_PARAMETERS);
v.reset().parameterAtIndexArray("nominationDate", i).value(extractString(nomineeJson, "nominationDate")).notBlank();
v.reset().parameterAtIndexArray("name", i).value(extractString(nomineeJson, "name")).notBlank().notExceedingLengthOf(100);
v.reset().parameterAtIndexArray("relation", i).value(extractString(nomineeJson, "relation")).notBlank()
.notExceedingLengthOf(40);
v.reset().parameterAtIndexArray("gender", i).value(extractString(nomineeJson, "gender")).notBlank()
.notExceedingLengthOf(20);
}
}
private String extractString(final JsonObject json, final String paramName) {
return json.has(paramName) && !json.get(paramName).isJsonNull() ? json.get(paramName).getAsString() : null;
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
dataValidationErrors);
}
}
}
@@ -1,29 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.service;
import java.util.Collection;
import org.apache.fineract.portfolio.dds.party.data.PartyData;
public interface PartyReadPlatformService {
Collection<PartyData> retrieveAll();
PartyData retrieveOne(Long partyId);
}
@@ -1,199 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.portfolio.dds.party.data.PartyData;
import org.apache.fineract.portfolio.dds.party.data.PartyNomineeData;
import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@RequiredArgsConstructor
public class PartyReadPlatformServiceImpl implements PartyReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final PartyMapper PARTY_MAPPER = new PartyMapper();
private static final PartyNomineeMapper NOMINEE_MAPPER = new PartyNomineeMapper();
private static final class PartyMapper implements RowMapper<PartyData> {
public String schema() {
return " p.id as id, p.client_id as clientId, c.display_name as clientName, p.agent_id as agentId, "
+ " a.agent_code as agentCode, a.agent_name as agentName, p.savings_account_id as savingsAccountId, "
+ " p.sub_type as subType, p.party_code as partyCode, p.last_account as lastAccount, p.nominal as nominal, "
+ " p.aadhar_no as aadharNo, p.party_name as partyName, p.party_name1 as partyName1, p.address1 as address1, "
+ " p.address2 as address2, p.address3 as address3, p.city as city, p.pincode as pincode, p.gender as gender, "
+ " p.pan_card_no as panCardNo, p.telephone as telephone, p.mobile_no_sms as mobileNoSms, p.operation as operation, "
+ " p.birth_date as birthDate, p.occupation as occupation, p.risk as risk, p.ckyc_id as ckycId, "
+ " p.adult_minor as adultMinor, p.minor_date as minorDate, p.guardian_client_id as guardianClientId, "
+ " p.min_account_opt as minAccountOpt, p.relation as relation, p.opening_date as openingDate, "
+ " p.months as month, p.maturity_date as maturityDate, p.reopen_date as reopenDate, "
+ " p.deposit_amount as depositAmount, p.daily_deposit_amount as dailyDepositAmount, p.days as days, "
+ " p.ledger_no as ledgerNo, p.maturity_amount as maturityAmount, p.interest_rate as interestRate, "
+ " p.withdraw_type as withdrawType, p.od1_flag as od1Flag, p.od2_flag as od2Flag, p.lien as lien, "
+ " p.pay_date as payDate, p.payment_amount as paymentAmount, p.current_balance as currentBalance, "
+ " p.opening_balance as openingBalance, p.is_sms_active as isSmsActive, p.is_email_active as isEmailActive, "
+ " p.interest_calc as interestCalc, p.interest_date as interestDate, p.sp_description as spDescription, "
+ " 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 "
+ " from m_dds_party p join m_client c on c.id = p.client_id join m_dds_agent a on a.id = p.agent_id";
}
@Override
public PartyData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
return PartyData.builder() //
.id(rs.getLong("id")) //
.clientId(rs.getLong("clientId")) //
.clientName(rs.getString("clientName")) //
.agentId(rs.getLong("agentId")) //
.agentCode(rs.getString("agentCode")) //
.agentName(rs.getString("agentName")) //
.savingsAccountId(JdbcSupport.getLong(rs, "savingsAccountId")) //
.subType(rs.getString("subType")) //
.partyCode(rs.getString("partyCode")) //
.lastAccount(rs.getString("lastAccount")) //
.nominal(rs.getBoolean("nominal")) //
.aadharNo(rs.getString("aadharNo")) //
.partyName(rs.getString("partyName")) //
.partyName1(rs.getString("partyName1")) //
.address1(rs.getString("address1")) //
.address2(rs.getString("address2")) //
.address3(rs.getString("address3")) //
.city(rs.getString("city")) //
.pincode(rs.getString("pincode")) //
.gender(rs.getString("gender")) //
.panCardNo(rs.getString("panCardNo")) //
.telephone(rs.getString("telephone")) //
.mobileNoSms(rs.getString("mobileNoSms")) //
.operation(rs.getString("operation")) //
.birthDate(JdbcSupport.getLocalDate(rs, "birthDate")) //
.occupation(rs.getString("occupation")) //
.risk(rs.getString("risk")) //
.ckycId(rs.getString("ckycId")) //
.adultMinor(rs.getString("adultMinor")) //
.minorDate(JdbcSupport.getLocalDate(rs, "minorDate")) //
.guardianClientId(JdbcSupport.getLong(rs, "guardianClientId")) //
.minAccountOpt(rs.getString("minAccountOpt")) //
.relation(rs.getString("relation")) //
.openingDate(JdbcSupport.getLocalDate(rs, "openingDate")) //
.month(JdbcSupport.getInteger(rs, "month")) //
.maturityDate(JdbcSupport.getLocalDate(rs, "maturityDate")) //
.reopenDate(JdbcSupport.getLocalDate(rs, "reopenDate")) //
.depositAmount(rs.getBigDecimal("depositAmount")) //
.dailyDepositAmount(rs.getBigDecimal("dailyDepositAmount")) //
.days(JdbcSupport.getInteger(rs, "days")) //
.ledgerNo(rs.getString("ledgerNo")) //
.maturityAmount(rs.getBigDecimal("maturityAmount")) //
.interestRate(rs.getBigDecimal("interestRate")) //
.withdrawType(rs.getString("withdrawType")) //
.od1Flag(rs.getBoolean("od1Flag")) //
.od2Flag(rs.getBoolean("od2Flag")) //
.lien(rs.getBoolean("lien")) //
.payDate(JdbcSupport.getLocalDate(rs, "payDate")) //
.paymentAmount(rs.getBigDecimal("paymentAmount")) //
.currentBalance(rs.getBigDecimal("currentBalance")) //
.openingBalance(rs.getBigDecimal("openingBalance")) //
.isSmsActive(rs.getBoolean("isSmsActive")) //
.isEmailActive(rs.getBoolean("isEmailActive")) //
.interestCalc(rs.getBigDecimal("interestCalc")) //
.interestDate(JdbcSupport.getLocalDate(rs, "interestDate")) //
.spDescription(rs.getString("spDescription")) //
.freezeCode(rs.getString("freezeCode")) //
.advCode(rs.getString("advCode")) //
.creditToBranch(rs.getString("creditToBranch")) //
.creditToAccountType(rs.getString("creditToAccountType")) //
.creditToAccount(rs.getString("creditToAccount")) //
.nomineeApplicable(rs.getBoolean("nomineeApplicable")) //
.nomineeSplitMode(rs.getString("nomineeSplitMode")) //
.status(rs.getString("status")) //
.closeDate(JdbcSupport.getLocalDate(rs, "closeDate")) //
.nominees(new ArrayList<>()) //
.build();
}
}
private static final class PartyNomineeMapper implements RowMapper<Map.Entry<Long, PartyNomineeData>> {
public String schema() {
return " n.party_id as partyId, n.id as id, n.nomination_date as nominationDate, n.name as name, "
+ " n.mobile_no as mobileNo, n.birth_date as birthDate, n.minor as minor, n.relation as relation, "
+ " n.gender as gender, n.guardian_name as guardianName from m_dds_party_nominee n";
}
@Override
public Map.Entry<Long, PartyNomineeData> mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final Long partyId = rs.getLong("partyId");
final PartyNomineeData data = PartyNomineeData.instance(rs.getLong("id"), JdbcSupport.getLocalDate(rs, "nominationDate"),
rs.getString("name"), rs.getString("mobileNo"), JdbcSupport.getLocalDate(rs, "birthDate"), rs.getBoolean("minor"),
rs.getString("relation"), rs.getString("gender"), rs.getString("guardianName"));
return Map.entry(partyId, data);
}
}
@Override
public Collection<PartyData> retrieveAll() {
final String sql = "select " + PARTY_MAPPER.schema() + " order by p.id";
final List<PartyData> parties = this.jdbcTemplate.query(sql, PARTY_MAPPER); // NOSONAR
return attachNominees(parties);
}
@Override
public PartyData retrieveOne(final Long partyId) {
try {
final String sql = "select " + PARTY_MAPPER.schema() + " where p.id = ?";
final PartyData party = this.jdbcTemplate.queryForObject(sql, PARTY_MAPPER, partyId); // NOSONAR
return attachNominees(List.of(party)).iterator().next();
} catch (final EmptyResultDataAccessException e) {
throw new PartyNotFoundException(partyId, e);
}
}
private Collection<PartyData> attachNominees(final List<PartyData> parties) {
if (parties.isEmpty()) {
return parties;
}
final String sql = "select " + NOMINEE_MAPPER.schema() + " order by n.id";
final List<Map.Entry<Long, PartyNomineeData>> rows = this.jdbcTemplate.query(sql, NOMINEE_MAPPER); // NOSONAR
final Map<Long, List<PartyNomineeData>> nomineesByPartyId = new LinkedHashMap<>();
for (final Map.Entry<Long, PartyNomineeData> row : rows) {
nomineesByPartyId.computeIfAbsent(row.getKey(), k -> new ArrayList<>()).add(row.getValue());
}
final List<PartyData> result = new ArrayList<>(parties.size());
for (final PartyData p : parties) {
result.add(p.toBuilder().nominees(nomineesByPartyId.getOrDefault(p.getId(), new ArrayList<>())).build());
}
return result;
}
}
@@ -1,31 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface PartyWritePlatformService {
CommandProcessingResult createParty(JsonCommand command);
CommandProcessingResult updateParty(Long partyId, JsonCommand command);
CommandProcessingResult closeParty(Long partyId, JsonCommand command);
}
@@ -1,235 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.service;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import jakarta.persistence.PersistenceException;
import java.math.BigDecimal;
import java.time.LocalDate;
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.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
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.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.dds.party.domain.Party;
import org.apache.fineract.portfolio.dds.party.domain.PartyNominee;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.apache.fineract.portfolio.dds.party.exception.PartyAlreadyClosedException;
import org.apache.fineract.portfolio.dds.party.exception.PartyHasBalanceException;
import org.apache.fineract.portfolio.dds.party.exception.PartyNotFoundException;
import org.apache.fineract.portfolio.dds.party.exception.PartySavingsAccountClientMismatchException;
import org.apache.fineract.portfolio.dds.party.exception.PartySavingsAccountNotFoundException;
import org.apache.fineract.portfolio.dds.party.serialization.PartyCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
public class PartyWritePlatformServiceJpaRepositoryImpl implements PartyWritePlatformService {
private final PartyCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final PartyRepository partyRepository;
private final AgentRepository agentRepository;
private final ClientRepositoryWrapper clientRepositoryWrapper;
private final SavingsAccountRepository savingsAccountRepository;
@Transactional
@Override
public CommandProcessingResult createParty(final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForCreate(command.json());
final Long clientId = command.longValueOfParameterNamed("clientId");
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
final Long agentId = command.longValueOfParameterNamed("agentId");
final Agent agent = this.agentRepository.findById(agentId)
.orElseThrow(() -> new AgentNotFoundException(agentId));
final String partyCode = generateNextPartyCode();
final Party party = Party.fromJson(client, agent, partyCode, command);
linkSavingsAccountIfProvided(party, clientId, command);
addNomineesFromCommand(party, command);
this.partyRepository.saveAndFlush(party);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(party.getId()) //
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
handleDataIntegrityIssues(dve.getMostSpecificCause(), dve);
return CommandProcessingResult.empty();
} catch (final PersistenceException dve) {
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
handleDataIntegrityIssues(throwable, dve);
return CommandProcessingResult.empty();
}
}
@Transactional
@Override
public CommandProcessingResult updateParty(final Long partyId, final JsonCommand command) {
try {
this.fromApiJsonDeserializer.validateForUpdate(command.json());
final Party partyForUpdate = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId));
final Map<String, Object> changesOnly = partyForUpdate.update(command);
if (changesOnly.containsKey("clientId")) {
final Long newClientId = (Long) changesOnly.get("clientId");
final Client newClient = this.clientRepositoryWrapper.findOneWithNotFoundDetection(newClientId);
partyForUpdate.changeClient(newClient);
}
if (changesOnly.containsKey("agentId")) {
final Long newAgentId = (Long) changesOnly.get("agentId");
final Agent newAgent = this.agentRepository.findById(newAgentId)
.orElseThrow(() -> new AgentNotFoundException(newAgentId));
partyForUpdate.changeAgent(newAgent);
}
if (command.parameterExists("nominees")) {
partyForUpdate.resetNominees();
addNomineesFromCommand(partyForUpdate, command);
changesOnly.put("nominees", command.jsonFragment("nominees"));
}
linkSavingsAccountIfProvided(partyForUpdate, partyForUpdate.clientId(), command);
this.partyRepository.saveAndFlush(partyForUpdate);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).with(changesOnly)
.build();
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
handleDataIntegrityIssues(dve.getMostSpecificCause(), dve);
return CommandProcessingResult.empty();
} catch (final PersistenceException dve) {
Throwable throwable = ExceptionUtils.getRootCause(dve.getCause());
handleDataIntegrityIssues(throwable, dve);
return CommandProcessingResult.empty();
}
}
@Transactional
@Override
public CommandProcessingResult closeParty(final Long partyId, final JsonCommand command) {
this.fromApiJsonDeserializer.validateForClose(command.json());
final Party party = this.partyRepository.findById(partyId).orElseThrow(() -> new PartyNotFoundException(partyId));
if (party.isClosed()) {
throw new PartyAlreadyClosedException(party.getPartyCode());
}
if (party.getCurrentBalance().compareTo(BigDecimal.ZERO) != 0) {
throw new PartyHasBalanceException(party.getPartyCode());
}
final LocalDate closeDate = command.localDateValueOfParameterNamed("closeDate");
party.close(closeDate);
this.partyRepository.saveAndFlush(party);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId).build();
}
/**
* If the caller gave a savingsAccountNo, links this Party to that real Savings Account - resolved
* by account number since that's what an officer actually has to hand, not the internal id. Left
* unlinked (as before) when the parameter isn't given: a Party doesn't require a real account,
* this is opportunistic linking for a client that already happens to have one.
*/
private void linkSavingsAccountIfProvided(final Party party, final Long expectedClientId, final JsonCommand command) {
if (!command.parameterExists("savingsAccountNo")) {
return;
}
final String savingsAccountNo = command.stringValueOfParameterNamed("savingsAccountNo");
final SavingsAccount savingsAccount = this.savingsAccountRepository.findNonClosedAccountByAccountNumber(savingsAccountNo);
if (savingsAccount == null) {
throw new PartySavingsAccountNotFoundException(savingsAccountNo);
}
if (!expectedClientId.equals(savingsAccount.clientId())) {
throw new PartySavingsAccountClientMismatchException(savingsAccountNo);
}
party.linkSavingsAccount(savingsAccount.getId());
}
private void addNomineesFromCommand(final Party party, final JsonCommand command) {
if (!command.parameterExists("nominees")) {
return;
}
final JsonArray nomineesArray = command.arrayOfParameterNamed("nominees");
if (nomineesArray == null) {
return;
}
for (int i = 0; i < nomineesArray.size(); i++) {
final JsonObject nomineeJson = nomineesArray.get(i).getAsJsonObject();
final LocalDate nominationDate = LocalDate.parse(nomineeJson.get("nominationDate").getAsString());
final String name = nomineeJson.get("name").getAsString();
final String mobileNo = nomineeJson.has("mobileNo") && !nomineeJson.get("mobileNo").isJsonNull()
? nomineeJson.get("mobileNo").getAsString()
: null;
final LocalDate birthDate = nomineeJson.has("birthDate") && !nomineeJson.get("birthDate").isJsonNull()
? LocalDate.parse(nomineeJson.get("birthDate").getAsString())
: null;
final boolean minor = nomineeJson.has("minor") && !nomineeJson.get("minor").isJsonNull() && nomineeJson.get("minor")
.getAsBoolean();
final String relation = nomineeJson.get("relation").getAsString();
final String gender = nomineeJson.get("gender").getAsString();
final String guardianName = nomineeJson.has("guardianName") && !nomineeJson.get("guardianName").isJsonNull()
? nomineeJson.get("guardianName").getAsString()
: null;
party.addNominee(new PartyNominee(nominationDate, name, mobileNo, birthDate, minor, relation, gender, guardianName));
}
}
private String generateNextPartyCode() {
final Long maxSuffix = this.partyRepository.retrieveMaxPartyCodeSuffix();
final long nextSuffix = (maxSuffix == null ? 0L : maxSuffix) + 1L;
return String.format("PT%04d", nextSuffix);
}
private void handleDataIntegrityIssues(final Throwable realCause, final Exception dve) {
if (realCause.getMessage() != null && realCause.getMessage().contains("party_code")) {
throw new PlatformDataIntegrityException("error.msg.party.duplicate.partyCode",
"A party with the generated code already exists, please retry");
}
log.error("Error occured.", dve);
throw ErrorHandler.getMappable(dve, "error.msg.party.unknown.data.integrity.issue",
"Unknown data integrity issue with resource: " + realCause.getMessage());
}
}
@@ -1,52 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.party.starter;
import org.apache.fineract.organisation.agent.domain.AgentRepository;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.dds.party.domain.PartyRepository;
import org.apache.fineract.portfolio.dds.party.serialization.PartyCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.dds.party.service.PartyReadPlatformService;
import org.apache.fineract.portfolio.dds.party.service.PartyReadPlatformServiceImpl;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformService;
import org.apache.fineract.portfolio.dds.party.service.PartyWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class DdsPartyConfiguration {
@Bean
@ConditionalOnMissingBean(PartyReadPlatformService.class)
public PartyReadPlatformService partyReadPlatformService(JdbcTemplate jdbcTemplate) {
return new PartyReadPlatformServiceImpl(jdbcTemplate);
}
@Bean
@ConditionalOnMissingBean(PartyWritePlatformService.class)
public PartyWritePlatformService partyWritePlatformService(PartyCommandFromApiJsonDeserializer fromApiJsonDeserializer,
PartyRepository partyRepository, AgentRepository agentRepository, ClientRepositoryWrapper clientRepositoryWrapper,
SavingsAccountRepository savingsAccountRepository) {
return new PartyWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, partyRepository, agentRepository,
clientRepositoryWrapper, savingsAccountRepository);
}
}
@@ -1,74 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.report.api;
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.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.report.data.DdsReportData;
import org.apache.fineract.portfolio.dds.report.service.DdsReportReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/reports/{reportKey}")
@Component
@Tag(name = "DDS Reports", description = "The DDS reports suite - one shared query engine behind a generic column/row shape, filtered by date range and agent/party id range.")
@RequiredArgsConstructor
public class DdsReportsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "DDSREPORT";
private final PlatformSecurityContext context;
private final DdsReportReadPlatformService readPlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Run a DDS Report", description = "reportKey is one of: AGENT_CHECK_LIST, PARTY_CHECK_LIST, PARTY_BALANCE, "
+ "AGENT_WISE_BALANCE, AGENT_WISE_MONTHLY_COLLECTION, COMMISSION_REGISTER, COLLECTION_SUMMARY_AGENT_WISE, "
+ "AGENT_PARTYWISE_DAILY_COLLECTION, DAILY_COLLECTION_REPORT, PARTY_INTEREST_REGISTER, BALANCE_SUMMARY, "
+ "TRANSACTION_REGISTER, MATURE_DATE_WISE, INTEREST_CERTIFICATE, AGENT_COMMISSION_STATEMENT, SUPPLEMENTARY_DDS, "
+ "CLOSE_PARTY_AC_REGISTER, MASTER_DATA_EXPORT (the last three always return available=false - no matching data exists "
+ "in this system yet). "
+ "fromDate/toDate are ISO (yyyy-MM-dd); agentFrom/To and partyFrom/To default to the full id range.")
public DdsReportData runReport(@PathParam("reportKey") @Parameter(description = "reportKey") final String reportKey,
@QueryParam("fromDate") @Parameter(description = "fromDate") final String fromDate,
@QueryParam("toDate") @Parameter(description = "toDate") final String toDate,
@DefaultValue("1") @QueryParam("agentFrom") @Parameter(description = "agentFrom") final Long agentFrom,
@DefaultValue("9223372036854775807") @QueryParam("agentTo") @Parameter(description = "agentTo") final Long agentTo,
@DefaultValue("1") @QueryParam("partyFrom") @Parameter(description = "partyFrom") final Long partyFrom,
@DefaultValue("9223372036854775807") @QueryParam("partyTo") @Parameter(description = "partyTo") final Long partyTo) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final LocalDate from = fromDate != null ? LocalDate.parse(fromDate) : LocalDate.of(1900, 1, 1);
final LocalDate to = toDate != null ? LocalDate.parse(toDate) : LocalDate.of(9999, 12, 31);
return readPlatformService.runReport(reportKey, from, to, agentFrom, agentTo, partyFrom, partyTo);
}
}
@@ -1,28 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.report.service;
import java.time.LocalDate;
import org.apache.fineract.portfolio.dds.report.data.DdsReportData;
public interface DdsReportReadPlatformService {
DdsReportData runReport(String reportKey, LocalDate fromDate, LocalDate toDate, Long agentFrom, Long agentTo, Long partyFrom,
Long partyTo);
}
@@ -1,313 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.report.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
import org.apache.fineract.portfolio.dds.report.data.DdsReportData;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* One switch over every DDS report key. Each branch is a real query against data this system
* actually has (Agent/Party/PartyTransaction/CommissionSlab/BatchRun).
*
* Since SubAgent was folded into Agent (2026-08-24, Agent now does the daily collection work
* directly), the queries here dropped a join level wherever they used to go Party/Transaction ->
* SubAgent -> Agent - they now join straight to Agent.
*/
@RequiredArgsConstructor
public class DdsReportReadPlatformServiceImpl implements DdsReportReadPlatformService {
private final JdbcTemplate jdbcTemplate;
@Override
public DdsReportData runReport(final String reportKey, final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo, final Long partyFrom, final Long partyTo) {
return switch (reportKey) {
case "AGENT_CHECK_LIST" -> agentCheckList();
case "PARTY_CHECK_LIST", "PARTY_BALANCE" -> partyList(agentFrom, agentTo, partyFrom, partyTo);
case "AGENT_WISE_BALANCE" -> agentWiseBalance(agentFrom, agentTo);
case "AGENT_WISE_MONTHLY_COLLECTION" -> agentWiseCollection(fromDate, toDate, agentFrom, agentTo);
case "COMMISSION_REGISTER" -> commissionRegister(fromDate, toDate, agentFrom, agentTo);
case "COLLECTION_SUMMARY_AGENT_WISE" -> collectionSummaryAgentWise(fromDate, toDate, agentFrom, agentTo);
case "AGENT_PARTYWISE_DAILY_COLLECTION" -> agentPartywiseDailyCollection(fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo);
case "DAILY_COLLECTION_REPORT" -> dailyCollectionReport(fromDate, toDate, agentFrom, agentTo);
case "PARTY_INTEREST_REGISTER" -> partyInterestRegister(fromDate, toDate, partyFrom, partyTo);
case "BALANCE_SUMMARY" -> balanceSummary(agentFrom, agentTo, partyFrom, partyTo);
case "TRANSACTION_REGISTER" -> transactionRegister(fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo);
case "MATURE_DATE_WISE" -> matureDateWise(fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo);
case "INTEREST_CERTIFICATE" -> interestCertificate(fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo);
case "AGENT_COMMISSION_STATEMENT" -> agentCommissionStatement(fromDate, toDate);
case "SUPPLEMENTARY_DDS" -> supplementaryDds(fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo);
case "CLOSE_PARTY_AC_REGISTER" -> closePartyAcRegister(fromDate, toDate, agentFrom, agentTo, partyFrom, partyTo);
case "MASTER_DATA_EXPORT" -> masterDataExport(agentFrom, agentTo, partyFrom, partyTo);
default -> throw new UnrecognizedQueryParamException("reportKey", reportKey);
};
}
private DdsReportData agentCheckList() {
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", a.agent_city as \"City\", "
+ "a.agent_current_balance as \"Current Balance\" from m_dds_agent a order by a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql);
return available("AGENT_CHECK_LIST", List.of("Agent Code", "Agent Name", "City", "Current Balance"), rows);
}
private DdsReportData partyList(final Long agentFrom, final Long agentTo, final Long partyFrom, final Long partyTo) {
final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", "
+ "a.agent_code as \"Agent Code\", p.current_balance as \"Current Balance\", "
+ "p.opening_date as \"Opening Date\" from m_dds_party p join m_dds_agent a on a.id = p.agent_id "
+ "where a.id between ? and ? and p.id between ? and ? order by p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, agentFrom, agentTo, partyFrom, partyTo);
return available("PARTY_CHECK_LIST", List.of("Party Code", "Party Name", "Agent Code", "Current Balance", "Opening Date"), rows);
}
private DdsReportData agentWiseBalance(final Long agentFrom, final Long agentTo) {
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Name\", "
+ "a.agent_current_balance as \"Current Balance\" from m_dds_agent a where a.id between ? and ? "
+ "order by a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, agentFrom, agentTo);
return available("AGENT_WISE_BALANCE", List.of("Agent Code", "Name", "Current Balance"), rows);
}
private DdsReportData agentWiseCollection(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo) {
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Name\", "
+ "coalesce(sum(t.amount), 0) as \"Total Collection\" from m_dds_agent a "
+ "left join m_dds_party_transaction t on t.agent_id = a.id and t.transaction_type = 'PARTY_TRAN' "
+ "and t.direction = 'CREDIT' and t.transaction_date between ? and ? " + "where a.id between ? and ? "
+ "group by a.id, a.agent_code, a.agent_name order by a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo);
return available("AGENT_WISE_MONTHLY_COLLECTION", List.of("Agent Code", "Name", "Total Collection"), rows);
}
private DdsReportData commissionRegister(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo) {
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Name\", b.job_date as \"Job Date\", "
+ "b.posted_on_date as \"Posted On\", r.amount as \"Commission Amount\" from m_dds_batch_run_row r "
+ "join m_dds_batch_run b on b.id = r.batch_run_id join m_dds_agent a on a.id = r.agent_id "
+ "where b.job_type = 'COMMISSION_CALCULATION' and b.status = 'POSTED' and b.posted_on_date between ? and ? "
+ "and a.id between ? and ? order by b.posted_on_date, a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo);
return available("COMMISSION_REGISTER", List.of("Agent Code", "Name", "Job Date", "Posted On", "Commission Amount"), rows);
}
private DdsReportData collectionSummaryAgentWise(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo) {
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", "
+ "coalesce(sum(t.amount), 0) as \"Total Collection\" from m_dds_agent a "
+ "left join m_dds_party_transaction t on t.agent_id = a.id and t.transaction_type = 'PARTY_TRAN' "
+ "and t.direction = 'CREDIT' and t.transaction_date between ? and ? " + "where a.id between ? and ? "
+ "group by a.id, a.agent_code, a.agent_name order by a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo);
return available("COLLECTION_SUMMARY_AGENT_WISE", List.of("Agent Code", "Agent Name", "Total Collection"), rows);
}
private DdsReportData agentPartywiseDailyCollection(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo, final Long partyFrom, final Long partyTo) {
final String sql = "select a.agent_code as \"Agent Code\", p.party_code as \"Party Code\", "
+ "t.transaction_date as \"Transaction Date\", t.amount as \"Amount\", "
+ "t.balance_after_transaction as \"Balance After\" from m_dds_party_transaction t "
+ "join m_dds_party p on p.id = t.party_id join m_dds_agent a on a.id = t.agent_id "
+ "where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT' and t.transaction_date between ? and ? "
+ "and a.id between ? and ? and p.id between ? and ? order by t.transaction_date, a.agent_code, p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo);
return available("AGENT_PARTYWISE_DAILY_COLLECTION",
List.of("Agent Code", "Party Code", "Transaction Date", "Amount", "Balance After"), rows);
}
private DdsReportData dailyCollectionReport(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo) {
final String sql = "select t.transaction_date as \"Transaction Date\", a.agent_code as \"Agent Code\", "
+ "coalesce(sum(t.amount), 0) as \"Total Collection\", count(*) as \"Transaction Count\" "
+ "from m_dds_party_transaction t join m_dds_agent a on a.id = t.agent_id "
+ "where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT' and t.transaction_date between ? and ? "
+ "and a.id between ? and ? group by t.transaction_date, a.id, a.agent_code "
+ "order by t.transaction_date, a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo);
return available("DAILY_COLLECTION_REPORT",
List.of("Transaction Date", "Agent Code", "Total Collection", "Transaction Count"), rows);
}
private DdsReportData partyInterestRegister(final LocalDate fromDate, final LocalDate toDate, final Long partyFrom,
final Long partyTo) {
final String sql = "select p.party_code as \"Party Code\", t.transaction_date as \"Transaction Date\", "
+ "t.transaction_type as \"Transaction Type\", t.amount as \"Amount\", "
+ "t.balance_after_transaction as \"Balance After\" from m_dds_party_transaction t "
+ "join m_dds_party p on p.id = t.party_id "
+ "where t.transaction_type in ('INTEREST_POST', 'OD_INTEREST_POST') and t.transaction_date between ? and ? "
+ "and p.id between ? and ? order by t.transaction_date, p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, partyFrom, partyTo);
return available("PARTY_INTEREST_REGISTER",
List.of("Party Code", "Transaction Date", "Transaction Type", "Amount", "Balance After"), rows);
}
private DdsReportData balanceSummary(final Long agentFrom, final Long agentTo, final Long partyFrom, final Long partyTo) {
final Integer totalAgents = this.jdbcTemplate.queryForObject("select count(*) from m_dds_agent where id between ? and ?",
Integer.class, agentFrom, agentTo);
final Integer totalParties = this.jdbcTemplate.queryForObject(
"select count(*) from m_dds_party where agent_id between ? and ? and id between ? and ?", Integer.class, agentFrom,
agentTo, partyFrom, partyTo);
final BigDecimal totalBalance = this.jdbcTemplate.queryForObject(
"select coalesce(sum(current_balance), 0) from m_dds_party where agent_id between ? and ? and id between ? and ?",
BigDecimal.class, agentFrom, agentTo, partyFrom, partyTo);
final Map<String, Object> row = Map.of("Total Agents", totalAgents, "Total Parties", totalParties, "Total Party Balance",
totalBalance);
return available("BALANCE_SUMMARY", List.of("Total Agents", "Total Parties", "Total Party Balance"), List.of(row));
}
private DdsReportData transactionRegister(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo, final Long partyFrom, final Long partyTo) {
final String sql = "select p.party_code as \"Party Code\", a.agent_code as \"Agent Code\", "
+ "t.transaction_date as \"Transaction Date\", t.transaction_type as \"Transaction Type\", "
+ "t.direction as \"Direction\", t.amount as \"Amount\", t.description as \"Description\" "
+ "from m_dds_party_transaction t join m_dds_party p on p.id = t.party_id "
+ "join m_dds_agent a on a.id = t.agent_id "
+ "where t.transaction_date between ? and ? and a.id between ? and ? and p.id between ? and ? "
+ "order by t.transaction_date, t.id";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo);
return available("TRANSACTION_REGISTER",
List.of("Party Code", "Agent Code", "Transaction Date", "Transaction Type", "Direction", "Amount", "Description"), rows);
}
private DdsReportData matureDateWise(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, final Long agentTo,
final Long partyFrom, final Long partyTo) {
final String sql = "select p.party_code as \"Party Code\", p.maturity_date as \"Maturity Date\", "
+ "p.current_balance as \"Current Balance\" from m_dds_party p "
+ "where p.maturity_date is not null and p.maturity_date between ? and ? and p.agent_id between ? and ? "
+ "and p.id between ? and ? order by p.maturity_date";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo);
return available("MATURE_DATE_WISE", List.of("Party Code", "Maturity Date", "Current Balance"), rows);
}
private DdsReportData interestCertificate(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo, final Long partyFrom, final Long partyTo) {
final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", "
+ "p.opening_date as \"Opening Date\", p.daily_deposit_amount as \"Daily Deposit Amount\", "
+ "p.current_balance as \"Current Balance\", coalesce((select sum(t.amount) from m_dds_party_transaction t "
+ "where t.party_id = p.id and t.transaction_type in ('INTEREST_POST', 'OD_INTEREST_POST') "
+ "and t.transaction_date between ? and ?), 0) as \"Interest Earned\" from m_dds_party p "
+ "where p.agent_id between ? and ? and p.id between ? and ? order by p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo);
return available("INTEREST_CERTIFICATE",
List.of("Party Code", "Party Name", "Opening Date", "Daily Deposit Amount", "Current Balance", "Interest Earned"), rows);
}
/**
* Per-agent settlement statement for a date range: collection volume, commission actually
* posted in that window (via the same BatchRunRow join as COMMISSION_REGISTER), and the
* deposit/advance/deduction fields tracked directly on the agent. "Deduct Commission" and
* "PC Diff" have no corresponding data anywhere in this schema yet, so they report as 0 rather
* than a guessed figure - matching the legacy sample where most of these columns were 0.00 too.
* No agent id range filter is applied here: the legacy screenshot for this report only ever
* showed a date range, no agent-range inputs.
*/
private DdsReportData agentCommissionStatement(final LocalDate fromDate, final LocalDate toDate) {
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", "
+ "a.agent_saving_account_no as \"A/c No\", coalesce(collection.total, 0) as \"Total Collection\", "
+ "coalesce(commission.total, 0) as \"Commission\", 0 as \"Deduct Commission\", "
+ "a.agent_deposit as \"Agent Deposit\", a.festival_advance as \"Festival Advance\", "
+ "a.other_deduction as \"Other Deduction\", "
+ "(coalesce(commission.total, 0) - a.agent_deposit - a.festival_advance - a.other_deduction) as \"Total\", "
+ "0 as \"PC Diff\" " + "from m_dds_agent a "
+ "left join (select t.agent_id as agent_id, sum(t.amount) as total from m_dds_party_transaction t "
+ "where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT' and t.transaction_date between ? and ? "
+ "group by t.agent_id) collection on collection.agent_id = a.id "
+ "left join (select r.agent_id as agent_id, sum(r.amount) as total from m_dds_batch_run_row r "
+ "join m_dds_batch_run b on b.id = r.batch_run_id "
+ "where b.job_type = 'COMMISSION_CALCULATION' and b.status = 'POSTED' and b.posted_on_date between ? and ? "
+ "group by r.agent_id) commission on commission.agent_id = a.id " + "order by a.agent_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, fromDate, toDate);
return available("AGENT_COMMISSION_STATEMENT",
List.of("Agent Code", "Agent Name", "A/c No", "Total Collection", "Commission", "Deduct Commission", "Agent Deposit",
"Festival Advance", "Other Deduction", "Total", "PC Diff"),
rows);
}
/**
* The legacy Supplementary-DDS (As On) report: every regular collection (PARTY_TRAN, CREDIT)
* posted in the given date range - clarified with the business as "the regular transactions
* going on" (2026-08-24), same shape as the other collection-listing reports.
*/
private DdsReportData supplementaryDds(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom, final Long agentTo,
final Long partyFrom, final Long partyTo) {
final String sql = "select a.agent_code as \"Agent Code\", p.party_code as \"Party Code\", p.party_name as \"Party Name\", "
+ "t.transaction_date as \"Transaction Date\", t.transaction_mode as \"Mode\", t.amount as \"Amount\", "
+ "t.balance_after_transaction as \"Balance After\" from m_dds_party_transaction t "
+ "join m_dds_party p on p.id = t.party_id join m_dds_agent a on a.id = t.agent_id "
+ "where t.transaction_type = 'PARTY_TRAN' and t.direction = 'CREDIT' and t.transaction_date between ? and ? "
+ "and a.id between ? and ? and p.id between ? and ? order by t.transaction_date, a.agent_code, p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo);
return available("SUPPLEMENTARY_DDS",
List.of("Agent Code", "Party Code", "Party Name", "Transaction Date", "Mode", "Amount", "Balance After"), rows);
}
/**
* The legacy Close Party Ac Register report: Parties closed (via the Close Party command) in the
* given date range, with their close date and final (zero) balance.
*/
private DdsReportData closePartyAcRegister(final LocalDate fromDate, final LocalDate toDate, final Long agentFrom,
final Long agentTo, final Long partyFrom, final Long partyTo) {
final String sql = "select p.party_code as \"Party Code\", p.party_name as \"Party Name\", a.agent_code as \"Agent Code\", "
+ "p.opening_date as \"Opening Date\", p.close_date as \"Close Date\", p.current_balance as \"Final Balance\" "
+ "from m_dds_party p join m_dds_agent a on a.id = p.agent_id "
+ "where p.status = 'CLOSED' and p.close_date between ? and ? and a.id between ? and ? and p.id between ? and ? "
+ "order by p.close_date, p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fromDate, toDate, agentFrom, agentTo, partyFrom,
partyTo);
return available("CLOSE_PARTY_AC_REGISTER",
List.of("Party Code", "Party Name", "Agent Code", "Opening Date", "Close Date", "Final Balance"), rows);
}
/**
* The legacy Master Data Export report: clarified with the business (2026-08-24) as "select an
* agent, show the full detail of every party under that agent" - filtered by the same agent
* range as every other report here (a single agent is just agentFrom == agentTo).
*/
private DdsReportData masterDataExport(final Long agentFrom, final Long agentTo, final Long partyFrom, final Long partyTo) {
final String sql = "select a.agent_code as \"Agent Code\", a.agent_name as \"Agent Name\", "
+ "p.party_code as \"Party Code\", p.party_name as \"Party Name\", p.address1 as \"Address\", "
+ "p.city as \"City\", p.pincode as \"Pincode\", p.mobile_no_sms as \"Mobile No\", "
+ "p.aadhar_no as \"Aadhar No\", p.pan_card_no as \"PAN No\", p.opening_date as \"Opening Date\", "
+ "p.maturity_date as \"Maturity Date\", p.daily_deposit_amount as \"Daily Deposit Amount\", "
+ "p.interest_rate as \"Interest Rate\", p.current_balance as \"Current Balance\" "
+ "from m_dds_party p join m_dds_agent a on a.id = p.agent_id "
+ "where a.id between ? and ? and p.id between ? and ? order by a.agent_code, p.party_code";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, agentFrom, agentTo, partyFrom, partyTo);
return available("MASTER_DATA_EXPORT",
List.of("Agent Code", "Agent Name", "Party Code", "Party Name", "Address", "City", "Pincode", "Mobile No", "Aadhar No",
"PAN No", "Opening Date", "Maturity Date", "Daily Deposit Amount", "Interest Rate", "Current Balance"),
rows);
}
private DdsReportData available(final String reportKey, final List<String> columns, final List<Map<String, Object>> rows) {
return DdsReportData.builder().reportKey(reportKey).available(true).columns(columns).rows(rows).build();
}
}
@@ -1,36 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.report.starter;
import org.apache.fineract.portfolio.dds.report.service.DdsReportReadPlatformService;
import org.apache.fineract.portfolio.dds.report.service.DdsReportReadPlatformServiceImpl;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class DdsReportConfiguration {
@Bean
@ConditionalOnMissingBean(DdsReportReadPlatformService.class)
public DdsReportReadPlatformService ddsReportReadPlatformService(JdbcTemplate jdbcTemplate) {
return new DdsReportReadPlatformServiceImpl(jdbcTemplate);
}
}
@@ -1,90 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.dds.transaction.api;
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.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.transaction.data.PartyTransactionData;
import org.apache.fineract.portfolio.dds.transaction.service.PartyTransactionReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/dds/partytransactions")
@Component
@Tag(name = "DDS Party Transactions", description = "The Daily Transaction Entry ledger: a day's PARTY_TRAN collection rows, the two legs of a WITHDRAW_DEPOSITE transfer between two parties, or an AGENT_TRAN settlement of an Agent's collected cash to its branch. Immutable once posted - reversed with an opposing entry, never edited.")
@RequiredArgsConstructor
public class PartyTransactionsApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "PARTYTRANSACTION";
private final PlatformSecurityContext context;
private final PartyTransactionReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve DDS Party Transactions", description = "Returns the list of posted DDS party transactions, most recent first. Optionally filter by agentId and/or partyId.")
public Collection<PartyTransactionData> retrieveAll(@QueryParam("agentId") @Parameter(description = "agentId") final Long agentId,
@QueryParam("partyId") @Parameter(description = "partyId") final Long partyId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveAll(agentId, partyId);
}
@GET
@Path("{transactionId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a DDS Party Transaction", description = "Returns the details of a single posted DDS party transaction.")
public PartyTransactionData retrieveOne(@PathParam("transactionId") @Parameter(description = "transactionId") final Long transactionId) {
context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return readPlatformService.retrieveOne(transactionId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Post DDS Party Transactions", description = "Mandatory fields: agentId, transactionType (PARTY_TRAN, "
+ "WITHDRAW_DEPOSITE or AGENT_TRAN), transactionDate\n" + "\n"
+ "For PARTY_TRAN: collections (array of {partyCode, transactionMode, accountType, accountNo, amount, chequeNo, tokenNo, "
+ "description})\n" + "\n" + "For WITHDRAW_DEPOSITE: creditorPartyCode, debitPartyCode, withdrawAmount, withdrawDescription\n"
+ "\n" + "For AGENT_TRAN: amount, transactionMode, accountType, accountNo, description")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder() //
.createPartyTransaction() //
.withJson(apiRequestBodyAsJson) //
.build();
return commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}

Some files were not shown because too many files have changed in this diff Show More