Compare commits

..
Author SHA1 Message Date
niteshandClaude Sonnet 5.5 a6dbe01cfc feat(dds): stronger DDS records, gapless account numbers, locked terms, slab validation
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
- Optimistic locks (version) on party, agent, batch run and collection entry
- Passbook lines keep branch, entry date, unique ref_no and the journal id they were
  booked under; journal legs carry the voucher; withdrawal legs of savings-backed
  parties are linked; journal ids no longer built from date and amount
- Audit user/date required with FKs on every DDS table; party tied to its product by
  id, with currency, activation and closure user; CHECK and FK constraints, indexes
- DDS account numbers from a locked serial (m_dds_account_number_serial) so rolled-back
  saves leave no gap; existing accounts renumbered from 1 (0624)
- Active DDS accounts: scheme terms and agent cannot be changed; closed accounts cannot
  be edited (validated in the API)
- Apply DDS receipt split by branch and product customer-deposits account
- Amounts limited to paise; batch runs record posted_amount and skipped rows; commission
  shared only over collections counted before the paying run
- Commission slabs: chart must start at 0, have no gap or overlap, percent 0-100, paise;
  correcting a slab moves its neighbour; 10 default Account slabs for a new bank (0625)
- Generating a batch preview replaces the previous un-posted preview of the same job;
  DELETE /dds/batchruns/{id} discards a preview
- Partytransactions API also returns refNo, branch, entry date, journal id, created by

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 17:13:24 +05:30
niteshandClaude Opus 5.5 422ea459be feat(dds): DDS product accounting tab; DDS posts only through product GLs
- DDS product names its own Customer Deposits, Interest Expense and
  Agent Commission Expense GLs (validated by kind), shown on the product
  view and in the approval review with old -> new on edits
- DdsLedger posts every DDS movement through the party's product GLs;
  commission is shared over the products an agent collected for
- dds-* settings removed from Global Configurations (0620); scheme
  accounts recognised by the DDS party linked to them, not a product id
- 0621 drops the agent cash, matured payable and current control GL
  columns, which nothing posts to
- Approval preview of interest/commission runs shows the product GLs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:43:41 +05:30
niteshandClaude Sonnet 5.5 6e89217e59 feat(dds): approvals review data, agent/party APIs, period-end interest, six-digit DDS GLs
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
- Post DDS interest at period end; drop Auto Interest Post Day (migration 0618)
- DDS approval review data for product, agent and party (fields, guarantors, nominees)
- Route DDS product/account/agent approvals to their own counters; add agent-approvals
- Party next code and last account no endpoint; customer search by clientId and mobileNo
- DDS GL codes to six digits with an agent GL (migration 0619)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:38:33 +05:30
keshav 7d9c5563a4 KYC management: notice queue, stage-wise audit, RD base chart
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 Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 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 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 7 of 10) (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 - PostgreSQL / 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 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-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (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 6 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-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 - 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-10-20 17:13:12 +05:30
niteshandClaude Opus 5.5 43c60223af feat(fixed-deposits): account-engine FD quote at opening, FD Special schedule and locked Special terms
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Documentation build / build (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 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 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 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
PROBLEM
- The account-opening screen quoted FD maturity with a stand-alone calculator
  (calculate-fd-interest) that counts posting periods from the deposit date.
  The account engine closes them at calendar period ends (from the financial-
  year start month), so the quote differed from the account once created,
  e.g. FD DOUBLE 546,348,939 @ 6.75% for 5 years from 2026-10-21, quarterly:
  quoted 763,660,633.77, account 763,697,613.00.
- FD Special (monthly interest payout) was quoted with compounding, and its
  product could be saved with Interest Calculation Type "Compounding".

CHANGE
1. POST /fixeddepositaccounts/account-preview (FixedDepositAccountPreviewService)
   Builds the would-be account from the request exactly as submitFDApplication
   does (DepositAccountAssembler.assembleFrom, updateMaturityDateAndAmount-
   BeforeAccountActivation, applyCbsFields) inside a transaction that is
   always rolled back - nothing is saved. Returns the applied rate, maturity
   date and maturity amount Submit would store, and the interest periods from
   FixedDepositAccount#projectInterestSchedule (for FD Special: the monthly
   payout schedule, same engine as the account's Monthly Interest Payout
   Schedule). Every FD type.
   Submit's linked-account checks (linkAccountId for FD Special,
   linkedAccountId for Deposit Account Link) get a stand-in id (-1) when not
   chosen yet: both are only recorded on the account, never looked up, and
   the account is never saved. Submit still requires them.
2. FdSpecialProductTermsValidator: an FD Special product must have
   interestCalculateSelector = Simple, interestCompoundingPeriodType = Monthly
   and interestPostingPeriodType = Monthly. Checked server-side on create,
   on update (also the maker-checker approval path) and on submit-for-
   approval, so Postman / any client gets a 400 validation error per field.
   Wired through SavingsConfiguration.

FILES
  api/FixedDepositAccountsApiResource.java            (account-preview endpoint)
  service/FixedDepositAccountPreviewService.java      (new)
  data/FdSpecialProductTermsValidator.java            (new)
  service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java
  starter/SavingsConfiguration.java

NOT CHANGED
  How the account calculates interest; calculate-fd-interest still exists but
  the opening screen no longer uses it.

DATA (not in this commit)
  Existing FD Special products saved as "Compounding" are refused on their
  next edit until set to Simple (the local tenant's FD SPECIAL was updated).

VERIFIED: compileJava. Numbers above reproduced independently. Not yet run
end-to-end through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:44:47 +05:30
niteshandClaude Opus 5.5 812eafc191 fix(accounting): clear savings-to-savings transfers through the product's Transfers In Suspense GL
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 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 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-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-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (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 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 2 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 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 - 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-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
PROBLEM
"Execute RD Auto Debit" reported success but no installment moved from the
linked savings account to the RD. Every account transfer between two
savings-type accounts (savings, FD, RD) books its clearing leg to the
organisation-wide Financial Activity "Liability Transfer" (200). That
activity was not mapped on this tenant, so the journal posting threw,
the transfer rolled back, and the job only logged a WARN ("RD Auto Debit
could not collect the installment ...") per installment. The tenant had
never completed a single account transfer (m_account_transfer_details
was empty).

CHANGE
The clearing leg now comes from the product's own "Transfers In
Suspense" mapping (acc_product_mapping, financial_account_type = 10),
which is set on the product's Accounting tab and can be changed there.

GL choice for one transfer (the same GL is used for both legs, so it
nets to nil):
  1. Transfers In Suspense of the RECEIVING product, else
  2. Transfers In Suspense of the PAYING product, else
  3. Financial Activity "Liability Transfer" (previous behaviour; still
     throws if that is not mapped either).
Savings products have no Transfers In Suspense field on screen, so for
savings -> RD/FD the RD/FD product's mapping is used.

Example, RD auto debit of 2,500 (Insta Money RD, suspense = 200901):
  Dr SAVING INDIVIDUAL (savings control)  Cr LIABILITY TRANSFER SUSPENSE
  Dr LIABILITY TRANSFER SUSPENSE          Cr RD ACCOUNT (RD control)

Undo / reversal of a transfer leg reverses through the clearing GL that
leg was ORIGINALLY posted to (read from acc_gl_journal_entry by
savings_transaction_id: the credit row of a withdrawal, the debit row of
a deposit). Transfers posted before this change (Liability Transfer)
and after it (product suspense) therefore both reverse to the account
they hit, even if the product mapping is changed later.

FILES
AccountingProcessorHelper
  - TRANSFER_CLEARING_ACCOUNT (ThreadLocal<GLAccount>)
  - useProductTransferSuspense(toProductId, fromProductId)
  - useClearingAccountPostedFor(savingsTransactionId, isWithdrawal)
  - clearProductTransferSuspense()
  - getLinkedGLAccountForSavingsProduct(): returns the thread's clearing
    account when FinancialActivity.LIABILITY_TRANSFER is asked for and
    one is set; otherwise unchanged.
AccountTransfersWritePlatformServiceImpl
  - transferFunds(), savings -> savings branch: handleWithdrawal and
    handleDeposit run inside useProductTransferSuspense / finally clear.
  - create() (Account Transfer screen/API), savings -> savings: same.
    The to-account is now assembled before the withdrawal (was after).
  - undoTransactions(): each savings leg is undone inside
    useClearingAccountPostedFor / finally clear.

FLOWS AFFECTED (savings-type <-> savings-type, via transferFunds/create)
  - RD Auto Debit job, RD activation from linked savings
  - FD activation funded from savings
  - FD maturity / premature / closure / payment paid to savings
  - RD closure / maturity payout to savings
  - Transfer Interest To Savings job (FD/RD interest)
  - Standing instructions and Account Transfer, savings -> savings/FD/RD
  - Fund transfer internal strategy and SuspenseGlService (savings -> savings)
  - Undo of any of the above (reverseTransfersWith..., reverseAllTransactions)

FLOWS NOT AFFECTED (still Financial Activity "Liability Transfer")
  - savings <-> loan (repayment, disbursement to savings, loan charges,
    guarantor recovery, Transfer Fee Charge For Loans job)
  - current account transfers
  - FD/RD closure "transfer to GL" (handleWithdrawal + manual JE against
    Liability Transfer in DepositAccountDomainServiceJpa)
  - branch-to-branch account transfer (already used Transfers In Suspense)
  - counter cash deposits / withdrawals

IF SOMETHING BREAKS - IS IT THIS CHANGE?
Likely this change if ALL of these hold:
  - the transaction is a transfer between two savings/FD/RD accounts (or
    its undo), and
  - the journal entry's clearing leg is on a product's Transfers In
    Suspense GL (e.g. 200901 LIABILITY TRANSFER SUSPENSE, or 200902
    SUNDRY CREDITORS for the FD products on the local tenant) instead of
    the Liability Transfer financial activity GL, or
  - a transfer's two legs land on DIFFERENT clearing GLs, or a reversal
    clears a different GL than the original (suspense balance left
    non-zero), or
  - a non-transfer posting inside the same request picked up the
    suspense GL (would mean the ThreadLocal leaked: look for a missing
    clearProductTransferSuspense()).
Not this change: savings <-> loan, current account, cash, or errors
"No Liability Transfer account is mapped" from the FD/RD close-to-GL path.

Quick checks:
  -- clearing legs of recent transfers
  SELECT j.transaction_id, j.type_enum, g.gl_code, g.name, j.amount
    FROM acc_gl_journal_entry j JOIN acc_gl_account g ON g.id = j.account_id
   WHERE j.savings_transaction_id IN (
         SELECT from_savings_transaction_id FROM m_account_transfer_transaction
         UNION SELECT to_savings_transaction_id FROM m_account_transfer_transaction)
   ORDER BY j.id DESC LIMIT 40;
  -- a clearing GL should net to zero over completed transfers
  SELECT SUM(CASE WHEN type_enum = 2 THEN amount ELSE -amount END)
    FROM acc_gl_journal_entry WHERE account_id = <suspense gl id>;

To go back to the old behaviour: revert this commit (no schema or data
change is involved; transfers already posted to a product suspense GL
still reverse correctly only while this commit is in place).

VERIFIED
  - :fineract-provider:compileJava passes.
  - Code paths traced: all transferFunds callers, create(), undoTransactions;
    single-leg undo of a transfer is blocked by undoTransaction
    (allowAccountTransferModification=false), so reversals go through
    undoTransactions.
NOT YET VERIFIED
  - No transfer has been executed end-to-end after this change (RD auto
    debit, FD activation from savings, undo). spotlessJavaCheck not run
    meaningfully: it already fails repo-wide on CRLF line endings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:20:54 +05:30
niteshandClaude Opus 5.5 b8ebfb3aeb Merge feature/approval-live-badge-counts into dev-latest
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 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 Documentation build / build (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-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (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 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 - MySQL / test (test-core-3) (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
Live pending-count badges on the Approval Center tabs and the Approvals menu,
served over one SSE stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:09:10 +05:30
niteshandClaude Opus 5.5 3a320b9c09 feat(fixed-deposits): FD funding rules and no FD withdrawals from clerk/cashier requests
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 3 of 10) (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 Documentation build / build (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-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-5) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 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 - 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 - 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
New FixedDepositFundingValidator, used on every path money can reach an FD:
- an FD is funded once, with exactly its principal: it must be active and not matured,
  hold no deposit yet, and the amount must equal its deposit amount;
- when funded from a Savings/Current account (clerk Bank/Fund Transfer), that account
  must be a Savings or Current account, active, not debit-frozen (sub-status BLOCK /
  BLOCK_DEBIT), out of lock-in, belong to the FD's client or be held jointly with them,
  and have enough available balance after holds, lien, clearing holds and minimum
  balance (plus overdraft for current accounts).

Applied in:
- FD deposit command (counter / FD screen / approved cashier deposit requests);
- cashier deposit request submit, so the counter sees the reason at once;
- clerk Disbursement / Collection / Fund Transfer / Bank Transfer, at submit and again
  at approval before the request is locked; one request funds FDs of one customer, an
  FD appears once, and an FD can never be a debit leg. After posting, each funded FD's
  maturity date and amount are recomputed (new
  DepositAccountWritePlatformService.recalculateFixedDepositMaturity), since the direct
  book credit does not do it;
- clerk withdrawal request submit refuses an FD: money leaves an FD only through its
  maturity or premature closure.

Previously a direct book debit in Collection / Bank Transfer could take money out of an
FD, bypassing the FD's own withdrawal refusal, and an FD could be credited any number of
times for any amount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:30:44 +05:30
niteshandClaude Opus 5.5 8ba0420398 feat(deposits): allow several FD/RD accounts on the same product for one client
A client may now open any number of Fixed Deposit and Recurring Deposit accounts on
the same product, even while an earlier one is still active. The one-active-account-
per-product check is removed from submitFDApplication and submitRDApplication only;
the same rule on plain Savings and Current accounts is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:30:44 +05:30
niteshandClaude Sonnet 5.5 6cfa7ef3d1 feat(approvals): live Approvals badge counts over one SSE stream
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 Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 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-5) (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 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 E2E Tests / E2E Tests (Shard 1 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 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-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (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-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
One endpoint now answers how many requests wait on every Approval Center tab,
so the screen no longer makes a call per tab.

- GET /v1/approvals/counts: snapshot, numbers only (held 3s, fresh=true skips).
- GET /v1/approvals/counts/stream: Server-Sent Events. A `counts` event is
  written the moment the numbers change; one shared 1s check serves every open
  screen and callers counted alike share one query. Keep-alive ping every 15s,
  stream recycled every 5 minutes.
- All 26 tabs are counted by one statement of scalar sub-selects, each the same
  table/status/office scope/exclusions as the tab's own API. A tab the caller may
  not see is not asked (permissions mirrored, disbursement for managers only).
- Layered: thin API resource, ApprovalCountsReadPlatformService (+Impl),
  ApprovalCountsStreamService, ApprovalCounterRegistry (one line per tab).
- Adds jersey-media-sse (SseEventSink / Sse).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:49:14 +05:30
niteshandClaude Sonnet 5.5 6be32272c1 feat(cashier): maker-checker on cash transfer
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 - 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 Documentation build / build (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 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 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 - 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
Cash no longer leaves the vault on one person's say-so. The allocation to a cashier is raised as a request and
posted only when a different manager or head cashier approves it.

- POST /cashtransfer/requests raises the request after every vault check (total and note by note); the same
  checks run again at approval, since the vault can change in between.
- Approve posts the transfer exactly as the direct one did and records it against the maker; reject needs a
  reason; the maker cannot approve their own request.
- An identical request from the same maker is refused while the first is still pending.
- The direct POST /cashtransfer is closed so the approval cannot be bypassed.
- Migration 0603 adds m_cash_transfer_request; m_cash_transfer and every position query are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:27:22 +05:30
niteshandClaude Sonnet 5.5 b47a34b5ef feat(vouchers): voucher correction for cash deposits posted to the wrong account
A cash deposit posted to the wrong savings account is corrected without ever undoing it behind anyone's back:
the voucher is unpassed (admin or clerk), a manager approves, the cashier enters the correct account, and a
manager approves again. Only the final approval moves money: the deposit is reversed on the wrong account, the
same cash is deposited into the right one under a new voucher, and the cashier's denominations follow it.

- VoucherCorrectionApiResource: eligible list, unpass, approve/reject unpass, account lookup, correction,
  approve/reject correction, close request, voided transactions.
- The wrong account's balance is checked when the cashier enters the correction and again at approval.
- A cashier whose customer has already used the money closes the request and deposits separately.
- Roles are enforced on the server: unpass for admin/clerk, correction and close for a cashier, approvals for
  a manager or head cashier other than the maker.
- Migration 0601 adds m_voucher_correction; 0602 drops the status columns an earlier draft put on m_voucher,
  since the correction status is read from m_voucher_correction alone.
- Voucher registers (register, My Vouchers, day book, voucher detail) report each voucher's correction status
  and leave a revoked voucher out of their totals, so its cash is not counted twice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:27:21 +05:30
180 changed files with 12140 additions and 581 deletions
@@ -80,6 +80,7 @@ public class Charge extends AbstractPersistableCustom<Long> {
@Column(name = "currency_code", length = 3) @Column(name = "currency_code", length = 3)
private String currencyCode; private String currencyCode;
@Getter
@Column(name = "charge_applies_to_enum", nullable = false) @Column(name = "charge_applies_to_enum", nullable = false)
private Integer chargeAppliesTo; private Integer chargeAppliesTo;
@@ -42,6 +42,8 @@ public enum JobName {
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), // POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), // UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), // APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
SEND_KYC_REMINDERS("Send KYC Reminders"), //
PROCESS_KYC_NOTICE_QUEUE("Process KYC Notice Queue"), //
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), // SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), // SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit Interest Posting Report"), // FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit Interest Posting Report"), //
@@ -28,6 +28,7 @@ import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy; import jakarta.persistence.OrderBy;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint; import jakarta.persistence.UniqueConstraint;
import jakarta.persistence.Version;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
@@ -61,6 +62,15 @@ import org.apache.fineract.portfolio.client.domain.Client;
@Table(name = "m_dds_agent", uniqueConstraints = { @UniqueConstraint(columnNames = { "agent_code" }, name = "agent_code_UNIQUE") }) @Table(name = "m_dds_agent", uniqueConstraints = { @UniqueConstraint(columnNames = { "agent_code" }, name = "agent_code_UNIQUE") })
public class Agent extends AbstractAuditableCustom { public class Agent extends AbstractAuditableCustom {
/**
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
* two collections at once, or a collection during a posting run - cannot both win; the second is
* refused and retried instead of quietly overwriting the first one's balance.
*/
@Version
@Column(name = "version", nullable = false)
private Long version;
@ManyToOne @ManyToOne
@JoinColumn(name = "client_id", nullable = false) @JoinColumn(name = "client_id", nullable = false)
private Client client; private Client client;
@@ -69,6 +69,16 @@ public class CommissionSlab extends AbstractAuditableCustom {
* Corrects this slab, reporting only what actually changed so the audit trail records the * Corrects this slab, reporting only what actually changed so the audit trail records the
* correction rather than a wholesale rewrite. * correction rather than a wholesale rewrite.
*/ */
/** Moves where this slab starts - used when the slab before it is corrected, so the two still meet. */
public void startAt(final BigDecimal from) {
this.slabFrom = from;
}
/** Moves where this slab ends - used when the slab after it is corrected, so the two still meet. */
public void endAt(final BigDecimal to) {
this.slabTo = to;
}
public Map<String, Object> update(final JsonCommand command) { public Map<String, Object> update(final JsonCommand command) {
final Map<String, Object> actualChanges = new LinkedHashMap<>(4); final Map<String, Object> actualChanges = new LinkedHashMap<>(4);
@@ -83,15 +83,24 @@ public class SavingsAccountSummaryData {
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately. * on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
*/ */
private final Integer renewalCycleNo; private final Integer renewalCycleNo;
/**
* The branch this account is held at: the account's own office when it was opened at another branch than the
* client's, else the client's (or the group's) office. Lets a customer with accounts at several branches tell
* them apart on the client page.
*/
private final Long officeId;
private final String officeName;
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId, 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 String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold, final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline, 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) { final String fdAccountNumber, final Integer renewalCycleNo, final Long officeId, final String officeName) {
this.fdAccountNumber = fdAccountNumber; this.fdAccountNumber = fdAccountNumber;
this.renewalCycleNo = renewalCycleNo; this.renewalCycleNo = renewalCycleNo;
this.officeId = officeId;
this.officeName = officeName;
this.id = id; this.id = id;
this.accountNo = accountNo; this.accountNo = accountNo;
this.externalId = externalId; this.externalId = externalId;
@@ -20,14 +20,12 @@ package org.apache.fineract.portfolio.aml.domain;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
@@ -40,11 +38,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
*/ */
@Entity @Entity
@Table(name = "m_aml_recurring_review") @Table(name = "m_aml_recurring_review")
public class AmlRecurringReview { public class AmlRecurringReview extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false) @ManyToOne(optional = false)
@JoinColumn(name = "client_id", nullable = false) @JoinColumn(name = "client_id", nullable = false)
@@ -107,10 +101,6 @@ public class AmlRecurringReview {
this.alertsActioned = count; this.alertsActioned = count;
} }
public Long getId() {
return id;
}
public Client getClient() { public Client getClient() {
return client; return client;
} }
@@ -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.portfolio.client.domain;
import java.util.regex.Pattern;
/**
* The comparable fingerprint of an Aadhaar number: the SHA-256 of the 12 digits, as 64 lowercase hex characters.
*
* <p>
* The hash is computed by the client (browser) and only the hash is sent. The KYC create/update calls go through the
* command source, which records the request body in an audit table and in the log, so the 12-digit number must not be
* in the body. The server therefore never sees the number and only checks that what it was given has the shape of a
* SHA-256 digest.
*
* <p>
* A plain, unkeyed SHA-256 of a 12-digit number can be reversed by trying every possible number, so this column is a
* lookup key, not protection against someone who can read the table.
*/
public final class AadhaarHash {
private static final Pattern SHA256_HEX = Pattern.compile("^[0-9a-f]{64}$");
private AadhaarHash() {}
/** True when the value has the shape of a lowercase hex SHA-256 digest. */
public static boolean isValid(final String value) {
return value != null && SHA256_HEX.matcher(value).matches();
}
/**
* The value trimmed and lower-cased when it is a well-formed digest; null for blank input. A non-blank value that
* is not a digest is rejected rather than silently dropped, so a client bug cannot disable the duplicate check.
*/
public static String normalizeOrNull(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
final String candidate = raw.trim().toLowerCase();
if (!isValid(candidate)) {
throw new IllegalArgumentException("aadhaarHash must be a 64-character hex SHA-256 digest");
}
return candidate;
}
}
@@ -151,6 +151,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "aadhaar_number_masked", length = 12) @Column(name = "aadhaar_number_masked", length = 12)
private String aadhaarNumberMasked; private String aadhaarNumberMasked;
/**
* SHA-256 of the 12-digit Aadhaar, computed client-side (see {@link AadhaarHash}). The masked form cannot tell two
* people apart; this can. Null for rows that predate the column and for clients with no Aadhaar.
*/
@Column(name = "aadhaar_hash", length = 64)
private String aadhaarHash;
@Column(name = "aadhaar_verified", nullable = false) @Column(name = "aadhaar_verified", nullable = false)
private boolean aadhaarVerified; private boolean aadhaarVerified;
@@ -391,6 +398,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
command.stringValueOfParameterNamed("aadhaarNumberMasked"), command.stringValueOfParameterNamed("aadhaarNumberMasked"),
command.stringValueOfParameterNamed("gstinNumber"), command.stringValueOfParameterNamed("gstinNumber"),
command.stringValueOfParameterNamed("remarks")); command.stringValueOfParameterNamed("remarks"));
kyc.aadhaarHash = AadhaarHash.normalizeOrNull(command.stringValueOfParameterNamed("aadhaarHash"));
kyc.applyExtendedFields(command); kyc.applyExtendedFields(command);
return kyc; return kyc;
} }
@@ -493,6 +501,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
actualChanges.put(aadhaarParam, newValue); actualChanges.put(aadhaarParam, newValue);
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked); recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null); this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
// The Aadhaar was removed: the fingerprint of the old number must not outlive it.
if (this.aadhaarNumberMasked == null) {
this.aadhaarHash = null;
}
}
// Only the fingerprint is sent, never the number. Reported as a flag, not a value, because the changes map
// is echoed back in the API response.
final String aadhaarHashParam = "aadhaarHash";
if (command.parameterExists(aadhaarHashParam)) {
final String newHash = AadhaarHash.normalizeOrNull(command.stringValueOfParameterNamed(aadhaarHashParam));
if (newHash != null && !newHash.equals(this.aadhaarHash)) {
this.aadhaarHash = newHash;
actualChanges.put("aadhaarIdentityUpdated", true);
}
} }
final String gstinParam = "gstinNumber"; final String gstinParam = "gstinNumber";
@@ -1050,6 +1073,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return aadhaarNumberMasked; return aadhaarNumberMasked;
} }
public String getAadhaarHash() {
return aadhaarHash;
}
public boolean isAadhaarVerified() { public boolean isAadhaarVerified() {
return aadhaarVerified; return aadhaarVerified;
} }
@@ -27,6 +27,13 @@ public interface ClientKycContactLogRepository extends JpaRepository<ClientKycCo
List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId); List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId);
/** True when a notice carrying this marker has already reached a final outcome on the channel. */
boolean existsByClientIdAndChannelAndNotesStartingWithAndDeliveryStatusIn(Long clientId, KycContactChannel channel,
String markerPrefix, java.util.Collection<KycContactDeliveryStatus> statuses);
/** How many times a marked notice has been tried on the channel — bounds the retries of a failing gateway. */
long countByClientIdAndChannelAndNotesStartingWith(Long clientId, KycContactChannel channel, String markerPrefix);
/** /**
* Every contact log for the given clients in one round trip, so the exceptions grid can render * Every contact log for the given clients in one round trip, so the exceptions grid can render
* "last contacted" for the whole page without an N+1 query per row. * "last contacted" for the whole page without an N+1 query per row.
@@ -61,6 +61,18 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
+ "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC") + "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus); List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus);
/**
* KYC records whose re-KYC falls due after {@code asOn} but no later than {@code until}, soonest first.
*
* <p>The complement of {@link #findLapsedAsOn}: a record is in one list or the other, never both, so the
* two together cover everything that needs the branch's attention. Rejected records are excluded for
* the same reason as there.
*/
@Query("SELECT k FROM ClientKyc k WHERE k.expiryDate IS NOT NULL AND k.expiryDate > :asOn "
+ "AND k.expiryDate <= :until AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
List<ClientKyc> findExpiringBetween(@Param("asOn") LocalDate asOn, @Param("until") LocalDate until,
@Param("rejectedStatus") Integer rejectedStatus);
// ── Pre-KYC uniqueness probes ─────────────────────────────────────────── // ── Pre-KYC uniqueness probes ───────────────────────────────────────────
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the // Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN // external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
@@ -110,11 +110,23 @@ public class ClientRiskRule {
@Column(name = "rule_kind", length = 10) @Column(name = "rule_kind", length = 10)
private String ruleKind; private String ruleKind;
/**
* Whether this scenario applies to Cash Credit accounts - a current account that is allowed to draw an overdraft
* against a sanctioned limit. Lets the Rules Master show, in one place, every scenario that watches a CC account,
* whatever its coverage status.
*/
@Column(name = "cc_relevant", nullable = false)
private boolean ccRelevant;
public static final String KIND_KYC = "KYC"; public static final String KIND_KYC = "KYC";
public static final String KIND_AML = "AML"; public static final String KIND_AML = "AML";
protected ClientRiskRule() {} protected ClientRiskRule() {}
public boolean isCcRelevant() {
return ccRelevant;
}
/** /**
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category * Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
* are attached separately by the caller, which owns the CodeValue lookup. * are attached separately by the caller, which owns the CodeValue lookup.
@@ -26,7 +26,8 @@ public interface SearchingClientRepository {
/** /**
* @param legalFormValue null = no filter; 1 = Individual only; 2 = Non-Individual only. * @param legalFormValue null = no filter; 1 = Individual only; 2 = Non-Individual only.
* Driven by the legal-form enum on Client (LegalForm.PERSON / .ENTITY). * Driven by the legal-form enum on Client (LegalForm.PERSON / .ENTITY).
* @param officeId null = no filter; otherwise only clients of that office, still inside officeHierarchy.
*/ */
Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly, Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly,
Integer legalFormValue); Integer legalFormValue, Long officeId);
} }
@@ -53,7 +53,7 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
@Override @Override
public Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly, public Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly,
Integer legalFormValue) { Integer legalFormValue, Long officeId) {
/* /*
* this whole thing can be replaced with Spring Data JPA 3+ with a findBy(Specification, Pageable) call but at * this whole thing can be replaced with Spring Data JPA 3+ with a findBy(Specification, Pageable) call but at
* this point the upgrade is too costly * this point the upgrade is too costly
@@ -62,6 +62,9 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
*/ */
String hierarchyLikeValue = officeHierarchy + "%"; String hierarchyLikeValue = officeHierarchy + "%";
// Customers of other branches that hold an account at the caller's branch are listed here too.
final List<Long> linkedClientIds = clientIdsWithAccountAtBranch(hierarchyLikeValue);
CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<SearchedClient> query = cb.createQuery(SearchedClient.class); CriteriaQuery<SearchedClient> query = cb.createQuery(SearchedClient.class);
@@ -74,7 +77,8 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
Join<Client, ClientIdentifier> identity = r.join("identifiers", JoinType.LEFT); Join<Client, ClientIdentifier> identity = r.join("identifiers", JoinType.LEFT);
List<Predicate> predicates = new ArrayList<>(); List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.like(o.get("hierarchy"), hierarchyLikeValue)); final Predicate inHierarchy = cb.like(o.get("hierarchy"), hierarchyLikeValue);
predicates.add(linkedClientIds.isEmpty() ? inHierarchy : cb.or(inHierarchy, r.get("id").in(linkedClientIds)));
String searchLikeValue = "%" + searchText + "%"; String searchLikeValue = "%" + searchText + "%";
predicates.add(cb.or(cb.like(r.get("accountNumber"), searchLikeValue), cb.like(r.get("cifNumber"), searchLikeValue), predicates.add(cb.or(cb.like(r.get("accountNumber"), searchLikeValue), cb.like(r.get("cifNumber"), searchLikeValue),
@@ -93,6 +97,11 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
predicates.add(cb.equal(r.get("legalForm"), legalFormValue)); predicates.add(cb.equal(r.get("legalForm"), legalFormValue));
} }
// Branch filter. ANDed with the hierarchy predicate above, so it can only narrow results.
if (officeId != null) {
predicates.add(cb.equal(o.get("id"), officeId));
}
return cb.and(predicates.toArray(new Predicate[0])); return cb.and(predicates.toArray(new Predicate[0]));
}; };
criteriaQueryFactory.applySpecificationToCriteria(root, spec, query); criteriaQueryFactory.applySpecificationToCriteria(root, spec, query);
@@ -108,4 +117,27 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
return criteriaQueryFactory.readPage(queryToExecute, Client.class, pageable, spec); return criteriaQueryFactory.readPage(queryToExecute, Client.class, pageable, spec);
} }
/**
* Ids of clients holding an account (savings, fixed or recurring deposit, or current) at an office at or below the
* given hierarchy pattern, whatever office the client itself belongs to. Only accounts opened at a branch other than
* the client's carry their own office, so this is empty for a branch with no such customers, and for Head Office.
* Being linked to a branch is deliberately not enough to be listed there.
*/
@SuppressWarnings("unchecked")
private List<Long> clientIdsWithAccountAtBranch(final String hierarchyLikeValue) {
final List<Object> rows = entityManager.createNativeQuery(
"SELECT sa.client_id FROM m_savings_account sa JOIN m_office ao ON ao.id = sa.office_id "
+ "WHERE sa.client_id IS NOT NULL AND ao.hierarchy LIKE ?1 "
+ "UNION SELECT ca.client_id FROM m_current_account ca JOIN m_office co ON co.id = ca.office_id "
+ "WHERE ca.client_id IS NOT NULL AND co.hierarchy LIKE ?2")
// Positional, not named: EclipseLink passes a native query's ":h" to PostgreSQL as written, which fails
// with a syntax error and took the whole client list down with it.
.setParameter(1, hierarchyLikeValue).setParameter(2, hierarchyLikeValue).getResultList();
final List<Long> ids = new ArrayList<>(rows.size());
for (final Object row : rows) {
ids.add(((Number) row).longValue());
}
return ids;
}
} }
@@ -41,7 +41,6 @@ public final class DdsAccountTypeData implements Serializable {
private final String accountTypeDescription; private final String accountTypeDescription;
private final Boolean autoInterestPost; private final Boolean autoInterestPost;
private final String autoInterestPostWhen; private final String autoInterestPostWhen;
private final Integer autoInterestPostDay;
/** Whether an account on this scheme closes itself on its maturity date. */ /** Whether an account on this scheme closes itself on its maturity date. */
private final Boolean maturityAutoClose; private final Boolean maturityAutoClose;
/** /**
@@ -56,6 +55,16 @@ public final class DdsAccountTypeData implements Serializable {
/** The scheme's annual interest rate, which an account opened on it takes. */ /** The scheme's annual interest rate, which an account opened on it takes. */
private final BigDecimal interestRate; private final BigDecimal interestRate;
private final Boolean active; private final Boolean active;
/** The product's own ledger accounts (see DdsAccountType), each with its code and name to show it by. */
private final Long depositLiabilityGlId;
private final String depositLiabilityGlCode;
private final String depositLiabilityGlName;
private final Long interestExpenseGlId;
private final String interestExpenseGlCode;
private final String interestExpenseGlName;
private final Long commissionExpenseGlId;
private final String commissionExpenseGlCode;
private final String commissionExpenseGlName;
private final LocalDateTime createdDate; private final LocalDateTime createdDate;
private final LocalDateTime lastModifiedDate; private final LocalDateTime lastModifiedDate;
} }
@@ -60,10 +60,6 @@ public class DdsAccountType extends AbstractAuditableCustom {
@Column(name = "auto_interest_post_when", length = 20) @Column(name = "auto_interest_post_when", length = 20)
private String autoInterestPostWhen; private String autoInterestPostWhen;
/** Day of the month interest posts on. Only meaningful when {@code autoInterestPost} is set. */
@Column(name = "auto_interest_post_day")
private Integer autoInterestPostDay;
/** /**
* Whether an account on this scheme closes itself the day it matures, instead of waiting for a * Whether an account on this scheme closes itself the day it matures, instead of waiting for a
* clerk to pay it out by hand. * clerk to pay it out by hand.
@@ -119,6 +115,24 @@ public class DdsAccountType extends AbstractAuditableCustom {
@Column(name = "is_active", nullable = false) @Column(name = "is_active", nullable = false)
private boolean active; private boolean active;
/**
* The ledger accounts this product posts through, chosen on its Accounting tab the way a savings
* product chooses its own. DDS posts through these alone - there is no bank-wide account to fall
* back on - so a product missing one stops its postings with a message naming what to set.
*
* What the bank owes this product's customers - DDS Customer Deposits, or the product's own.
*/
@Column(name = "deposit_liability_gl_id")
private Long depositLiabilityGlId;
/** What interest paid to this product's customers costs the bank. */
@Column(name = "interest_expense_gl_id")
private Long interestExpenseGlId;
/** What the agents' commission on collecting for this product costs the bank. */
@Column(name = "commission_expense_gl_id")
private Long commissionExpenseGlId;
protected DdsAccountType() { protected DdsAccountType() {
// //
} }
@@ -130,14 +144,14 @@ public class DdsAccountType extends AbstractAuditableCustom {
this.autoInterestPost = command.booleanPrimitiveValueOfParameterNamed("autoInterestPost"); this.autoInterestPost = command.booleanPrimitiveValueOfParameterNamed("autoInterestPost");
this.autoInterestPostWhen = StringUtils this.autoInterestPostWhen = StringUtils
.defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("autoInterestPostWhen"), null); .defaultIfEmpty(command.stringValueOfParameterNamedAllowingNull("autoInterestPostWhen"), null);
this.autoInterestPostDay = this.autoInterestPost
? command.integerValueOfParameterNamedDefaultToNullIfZero("autoInterestPostDay")
: null;
this.maturityAutoClose = command.booleanPrimitiveValueOfParameterNamed("maturityAutoClose"); this.maturityAutoClose = command.booleanPrimitiveValueOfParameterNamed("maturityAutoClose");
this.prematureLockinMonths = valueOr(command, "prematureLockinMonths", DEFAULT_LOCKIN_MONTHS); this.prematureLockinMonths = valueOr(command, "prematureLockinMonths", DEFAULT_LOCKIN_MONTHS);
this.prematureInterestReduction = valueOr(command, "prematureInterestReduction", DEFAULT_INTEREST_REDUCTION); this.prematureInterestReduction = valueOr(command, "prematureInterestReduction", DEFAULT_INTEREST_REDUCTION);
this.prematureAgentCommissionPenalty = valueOr(command, "prematureAgentCommissionPenalty", DEFAULT_AGENT_PENALTY); this.prematureAgentCommissionPenalty = valueOr(command, "prematureAgentCommissionPenalty", DEFAULT_AGENT_PENALTY);
this.interestRate = command.bigDecimalValueOfParameterNamed("interestRate"); this.interestRate = command.bigDecimalValueOfParameterNamed("interestRate");
this.depositLiabilityGlId = command.longValueOfParameterNamed("depositLiabilityGlId");
this.interestExpenseGlId = command.longValueOfParameterNamed("interestExpenseGlId");
this.commissionExpenseGlId = command.longValueOfParameterNamed("commissionExpenseGlId");
// A newly created account type is available for use straight away. // A newly created account type is available for use straight away.
this.active = !command.parameterExists("active") || command.booleanPrimitiveValueOfParameterNamed("active"); this.active = !command.parameterExists("active") || command.booleanPrimitiveValueOfParameterNamed("active");
} }
@@ -212,23 +226,12 @@ public class DdsAccountType extends AbstractAuditableCustom {
this.autoInterestPostWhen = StringUtils.defaultIfEmpty(newValue, null); this.autoInterestPostWhen = StringUtils.defaultIfEmpty(newValue, null);
} }
if (command.isChangeInIntegerParameterNamed("autoInterestPostDay", this.autoInterestPostDay)) {
final Integer newValue = command.integerValueOfParameterNamedDefaultToNullIfZero("autoInterestPostDay");
actualChanges.put("autoInterestPostDay", newValue);
this.autoInterestPostDay = newValue;
}
if (command.isChangeInBooleanParameterNamed("maturityAutoClose", this.maturityAutoClose)) { if (command.isChangeInBooleanParameterNamed("maturityAutoClose", this.maturityAutoClose)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("maturityAutoClose"); final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("maturityAutoClose");
actualChanges.put("maturityAutoClose", newValue); actualChanges.put("maturityAutoClose", newValue);
this.maturityAutoClose = newValue; this.maturityAutoClose = newValue;
} }
// A posting day only means anything while auto-posting is on.
if (!this.autoInterestPost) {
this.autoInterestPostDay = null;
}
if (command.isChangeInIntegerParameterNamed("prematureLockinMonths", this.prematureLockinMonths)) { if (command.isChangeInIntegerParameterNamed("prematureLockinMonths", this.prematureLockinMonths)) {
final Integer newValue = command.integerValueOfParameterNamedDefaultToNullIfZero("prematureLockinMonths"); final Integer newValue = command.integerValueOfParameterNamedDefaultToNullIfZero("prematureLockinMonths");
actualChanges.put("prematureLockinMonths", newValue); actualChanges.put("prematureLockinMonths", newValue);
@@ -256,6 +259,23 @@ public class DdsAccountType extends AbstractAuditableCustom {
this.interestRate = newValue; this.interestRate = newValue;
} }
// The product's ledger accounts. All three are required; the deserializer refuses a null.
if (command.parameterExists("depositLiabilityGlId")
&& command.isChangeInLongParameterNamed("depositLiabilityGlId", this.depositLiabilityGlId)) {
this.depositLiabilityGlId = command.longValueOfParameterNamed("depositLiabilityGlId");
actualChanges.put("depositLiabilityGlId", this.depositLiabilityGlId);
}
if (command.parameterExists("interestExpenseGlId")
&& command.isChangeInLongParameterNamed("interestExpenseGlId", this.interestExpenseGlId)) {
this.interestExpenseGlId = command.longValueOfParameterNamed("interestExpenseGlId");
actualChanges.put("interestExpenseGlId", this.interestExpenseGlId);
}
if (command.parameterExists("commissionExpenseGlId")
&& command.isChangeInLongParameterNamed("commissionExpenseGlId", this.commissionExpenseGlId)) {
this.commissionExpenseGlId = command.longValueOfParameterNamed("commissionExpenseGlId");
actualChanges.put("commissionExpenseGlId", this.commissionExpenseGlId);
}
if (command.isChangeInBooleanParameterNamed("active", this.active)) { if (command.isChangeInBooleanParameterNamed("active", this.active)) {
final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active"); final boolean newValue = command.booleanPrimitiveValueOfParameterNamed("active");
actualChanges.put("active", newValue); actualChanges.put("active", newValue);
@@ -25,6 +25,7 @@ import jakarta.persistence.FetchType;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy; import jakarta.persistence.OrderBy;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
@@ -45,6 +46,15 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
@Table(name = "m_dds_batch_run") @Table(name = "m_dds_batch_run")
public class BatchRun extends AbstractAuditableCustom { public class BatchRun extends AbstractAuditableCustom {
/**
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
* two collections at once, or a collection during a posting run - cannot both win; the second is
* refused and retried instead of quietly overwriting the first one's balance.
*/
@Version
@Column(name = "version", nullable = false)
private Long version;
@Column(name = "job_type", length = 40, nullable = false) @Column(name = "job_type", length = 40, nullable = false)
private String jobType; private String jobType;
@@ -87,6 +97,14 @@ public class BatchRun extends AbstractAuditableCustom {
@Column(name = "rows_posted", nullable = false) @Column(name = "rows_posted", nullable = false)
private Integer rowsPosted = 0; private Integer rowsPosted = 0;
/**
* What the run actually moved, beside totalAmount - what it was generated to move. They differ when
* rows were passed over at posting (the money had already been paid by another run), and the run
* says so rather than reading as though the whole total went through.
*/
@Column(name = "posted_amount", scale = 6, precision = 19)
private BigDecimal postedAmount;
/** Why a run stopped, when it did. A background run has nowhere else to say so. */ /** Why a run stopped, when it did. A background run has nowhere else to say so. */
@Column(name = "failure_reason", length = 500) @Column(name = "failure_reason", length = 500)
private String failureReason; private String failureReason;
@@ -155,6 +173,11 @@ public class BatchRun extends AbstractAuditableCustom {
this.rowsPosted = posted; this.rowsPosted = posted;
} }
public void recordPosted(final int posted, final BigDecimal amount) {
this.rowsPosted = posted;
this.postedAmount = amount;
}
public void markPosted(final LocalDate postedOnDate) { public void markPosted(final LocalDate postedOnDate) {
this.status = STATUS_POSTED; this.status = STATUS_POSTED;
this.postedOnDate = postedOnDate; this.postedOnDate = postedOnDate;
@@ -23,6 +23,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import lombok.Getter; import lombok.Getter;
@@ -49,6 +50,15 @@ import org.apache.fineract.portfolio.dds.party.domain.Party;
@Table(name = "m_dds_collection_entry") @Table(name = "m_dds_collection_entry")
public class CollectionEntry extends AbstractAuditableCustom { public class CollectionEntry extends AbstractAuditableCustom {
/**
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
* two collections at once, or a collection during a posting run - cannot both win; the second is
* refused and retried instead of quietly overwriting the first one's balance.
*/
@Version
@Column(name = "version", nullable = false)
private Long version;
/** Waiting for a clerk to count the cash behind it. */ /** Waiting for a clerk to count the cash behind it. */
public static final String PENDING = "PENDING"; public static final String PENDING = "PENDING";
@@ -178,5 +178,11 @@ public final class PartyData implements Serializable {
private final String nomineeSplitMode; private final String nomineeSplitMode;
private final String status; private final String status;
private final LocalDate closeDate; private final LocalDate closeDate;
/** The product by id, the account currency, and who opened (made operative) and closed it - as a savings account shows. */
private final Long accountTypeId;
private final String currencyCode;
private final LocalDate activatedOnDate;
private final String activatedByUsername;
private final String closedByUsername;
private final Collection<PartyNomineeData> nominees; private final Collection<PartyNomineeData> nominees;
} }
@@ -28,6 +28,7 @@ import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy; import jakarta.persistence.OrderBy;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint; import jakarta.persistence.UniqueConstraint;
import jakarta.persistence.Version;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.MathContext; import java.math.MathContext;
import java.math.RoundingMode; import java.math.RoundingMode;
@@ -60,6 +61,15 @@ import org.apache.fineract.portfolio.client.domain.Client;
@Table(name = "m_dds_party", uniqueConstraints = { @UniqueConstraint(columnNames = { "party_code" }, name = "party_code_UNIQUE") }) @Table(name = "m_dds_party", uniqueConstraints = { @UniqueConstraint(columnNames = { "party_code" }, name = "party_code_UNIQUE") })
public class Party extends AbstractAuditableCustom { public class Party extends AbstractAuditableCustom {
/**
* Optimistic lock, as a savings account has: two saves of the same row from different requests -
* two collections at once, or a collection during a posting run - cannot both win; the second is
* refused and retried instead of quietly overwriting the first one's balance.
*/
@Version
@Column(name = "version", nullable = false)
private Long version;
/** DDS accounts carry a flat interest rate unless the officer explicitly overrides it at creation. */ /** 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"); private static final BigDecimal DEFAULT_INTEREST_RATE = new BigDecimal("2");
@@ -304,6 +314,29 @@ public class Party extends AbstractAuditableCustom {
@Column(name = "close_date") @Column(name = "close_date")
private LocalDate closeDate; private LocalDate closeDate;
/**
* The DDS product the account is on, by id - subType carries its code for the screens, but a code
* can be renamed and an id cannot, so this is what ties the account to its product's terms and
* ledger accounts (as m_savings_account.product_id does).
*/
@Column(name = "account_type_id")
private Long accountTypeId;
/** The account's currency, as a savings account records its own - set from the bank's currency on opening. */
@Column(name = "currency_code", length = 3)
private String currencyCode;
/** When the account became operative, and who made it so - the checker, where the opening was approved. */
@Column(name = "activatedon_date")
private LocalDate activatedOnDate;
@Column(name = "activatedon_userid")
private Long activatedByUserId;
/** Who closed the account, beside closeDate - when it was closed. */
@Column(name = "closedon_userid")
private Long closedByUserId;
@OneToMany(mappedBy = "party", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @OneToMany(mappedBy = "party", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("id") @OrderBy("id")
private List<PartyNominee> nominees = new ArrayList<>(); private List<PartyNominee> nominees = new ArrayList<>();
@@ -492,10 +525,13 @@ public class Party extends AbstractAuditableCustom {
this.savingsAccountId = savingsAccountId; this.savingsAccountId = savingsAccountId;
} }
/** Numbers the account from its id, the same way 0514 numbered the accounts already open. */ /**
public void assignAccountNo() { * Numbers the account from the DDS account serial (m_dds_account_number_serial), not from its id: an
if (this.accountNo == null && getId() != null) { * id is spent by every save that rolls back, and the bank's account numbers must run without gaps.
this.accountNo = "DDS" + String.format("%09d", getId()); */
public void assignAccountNo(final long serial) {
if (this.accountNo == null) {
this.accountNo = "DDS" + String.format("%09d", serial);
} }
} }
@@ -539,9 +575,22 @@ public class Party extends AbstractAuditableCustom {
* is settled to zero (a full Withdraw Deposite/payout) before it is closed, not closed with money * is settled to zero (a full Withdraw Deposite/payout) before it is closed, not closed with money
* still owed to it. * still owed to it.
*/ */
public void close(final LocalDate closeDate) { public void close(final LocalDate closeDate, final Long closedBy) {
this.status = STATUS_CLOSED; this.status = STATUS_CLOSED;
this.closeDate = closeDate; this.closeDate = closeDate;
this.closedByUserId = closedBy;
}
/** Ties the account to its product by id; called whenever subType is set or changed. */
public void linkProduct(final Long productId) {
this.accountTypeId = productId;
}
/** Stamps the opening: the account's currency, and the day and user that made it operative. */
public void recordActivation(final String currency, final LocalDate on, final Long by) {
this.currencyCode = currency;
this.activatedOnDate = on;
this.activatedByUserId = by;
} }
/** /**
@@ -22,6 +22,7 @@ import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@@ -51,4 +52,15 @@ public final class PartyTransactionData implements Serializable {
private final BigDecimal balanceAfterTransaction; private final BigDecimal balanceAfterTransaction;
/** The DDS voucher this line moved under, e.g. DDS/29092026/0001; empty on lines from before vouchers. */ /** The DDS voucher this line moved under, e.g. DDS/29092026/0001; empty on lines from before vouchers. */
private final String voucherNo; private final String voucherNo;
/** The branch the line is booked at, the business day it was recorded, and the journal it was booked under. */
private final Long officeId;
private final String officeName;
private final LocalDate submittedOnDate;
private final String journalTransactionId;
/** The line's own unique reference (m_dds_party_transaction.ref_no). */
private final String refNo;
/** Who keyed the line and when, as a savings transaction shows it. */
private final String createdByUsername;
private final LocalDateTime createdDate;
private final String agentName;
} }
@@ -23,11 +23,14 @@ import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.PostPersist; import jakarta.persistence.PostPersist;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.UUID;
import lombok.Getter; import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.agent.domain.Agent; import org.apache.fineract.organisation.agent.domain.Agent;
import org.apache.fineract.portfolio.dds.party.domain.Party; import org.apache.fineract.portfolio.dds.party.domain.Party;
import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope; import org.apache.fineract.portfolio.dds.voucher.DdsVoucherScope;
@@ -87,6 +90,36 @@ public class PartyTransaction extends AbstractAuditableCustom {
@Column(name = "balance_after_transaction", scale = 6, precision = 19, nullable = false) @Column(name = "balance_after_transaction", scale = 6, precision = 19, nullable = false)
private BigDecimal balanceAfterTransaction; private BigDecimal balanceAfterTransaction;
/** The branch the line is booked at - the party's, or its agent's for a line with no party - as savings rows carry. */
@Column(name = "office_id")
private Long officeId;
/**
* The business day the line was recorded, beside transactionDate - the day the money moved. They
* differ when a collection is applied from a cashier's receipt days after it was collected.
*/
@Column(name = "submitted_on_date")
private LocalDate submittedOnDate;
/**
* The acc_gl_journal_entry.transaction_id the line was booked under, so a passbook line and its
* journal can be found from each other. Null for a line that moves nothing on the books (a transfer
* between two customers owed on the same account).
*/
@Column(name = "journal_transaction_id", length = 100)
private String journalTransactionId;
/**
* The line's own reference, unique across DDS, as a savings transaction carries ref_no: what a
* statement, a complaint or a reconciliation quotes to name exactly one line.
*/
@Column(name = "ref_no", length = 40, nullable = false, unique = true)
private String refNo;
/** The voucher the line was printed on - stamped by DdsVoucherService once the operation's voucher is raised. */
@Column(name = "voucher_no", length = 30, insertable = false, updatable = false)
private String voucherNo;
protected PartyTransaction() { protected PartyTransaction() {
// //
} }
@@ -108,6 +141,31 @@ public class PartyTransaction extends AbstractAuditableCustom {
this.tokenNo = tokenNo; this.tokenNo = tokenNo;
this.description = description; this.description = description;
this.balanceAfterTransaction = balanceAfterTransaction; this.balanceAfterTransaction = balanceAfterTransaction;
if (party != null && party.getOffice() != null) {
this.officeId = party.getOffice().getId();
} else if (agent != null && agent.getOffice() != null) {
this.officeId = agent.getOffice().getId();
} else if (agent != null && agent.getClient() != null && agent.getClient().getOffice() != null) {
// An agent saved without a branch is still a client of one.
this.officeId = agent.getClient().getOffice().getId();
}
this.submittedOnDate = DateUtils.getBusinessLocalDate();
}
/** Names the journal this line was booked under, where the caller knows it better than the operation's last one. */
public PartyTransaction linkJournal(final String transactionId) {
this.journalTransactionId = transactionId;
return this;
}
@PrePersist
private void linkToJournal() {
if (this.refNo == null) {
this.refNo = "DDST" + UUID.randomUUID().toString().replace("-", "").substring(0, 20).toUpperCase();
}
if (this.journalTransactionId == null) {
this.journalTransactionId = DdsVoucherScope.lastLedgerTransaction();
}
} }
/** /**
@@ -48,6 +48,8 @@ public final class DdsVoucherScope {
private final Set<String> ledgerTransactionIds = new LinkedHashSet<>(); private final Set<String> ledgerTransactionIds = new LinkedHashSet<>();
private final List<Long> partyTransactionIds = new ArrayList<>(); private final List<Long> partyTransactionIds = new ArrayList<>();
private final List<Line> extraLines = new ArrayList<>(); private final List<Line> extraLines = new ArrayList<>();
/** The journal most recently written in this operation - what the next passbook line saved belongs to. */
private String lastLedgerTransactionId;
private DdsVoucherScope() {} private DdsVoucherScope() {}
@@ -72,6 +74,24 @@ public final class DdsVoucherScope {
final DdsVoucherScope scope = CURRENT.get(); final DdsVoucherScope scope = CURRENT.get();
if (scope != null && transactionId != null) { if (scope != null && transactionId != null) {
scope.ledgerTransactionIds.add(transactionId); scope.ledgerTransactionIds.add(transactionId);
scope.lastLedgerTransactionId = transactionId;
}
}
/**
* The journal the passbook line being saved now belongs to: every DDS movement writes its journal
* first and its passbook line straight after, so it is the last one written in this operation.
*/
public static String lastLedgerTransaction() {
final DdsVoucherScope scope = CURRENT.get();
return scope == null ? null : scope.lastLedgerTransactionId;
}
/** For a movement that writes no journal, so its passbook line does not claim the previous one's. */
public static void forgetLastLedgerTransaction() {
final DdsVoucherScope scope = CURRENT.get();
if (scope != null) {
scope.lastLedgerTransactionId = null;
} }
} }
@@ -32,6 +32,7 @@ public enum NoteType {
GROUP(600, "noteType.group", "groups", "Group note"), // GROUP(600, "noteType.group", "groups", "Group note"), //
SHARE_ACCOUNT(700, "noteType.shares", "accounts/share", "Share account note"), // SHARE_ACCOUNT(700, "noteType.shares", "accounts/share", "Share account note"), //
SAVINGS_TRANSACTION(800, "noteType.savings.transaction", "savingsTransactions", "Savings transaction note"), // SAVINGS_TRANSACTION(800, "noteType.savings.transaction", "savingsTransactions", "Savings transaction note"), //
CURRENT_ACCOUNT(900, "noteType.currentaccount", "currentaccounts", "Current account note"), //
; ;
public static final NoteType[] VALUES = values(); public static final NoteType[] VALUES = values();
@@ -28,6 +28,14 @@ public interface CurrentAccountReadPlatformService {
Map<String, Object> retrieveOneTransaction(Long accountId, Long transactionId); Map<String, Object> retrieveOneTransaction(Long accountId, Long transactionId);
/** The account's charges from m_current_account_charge; chargeStatus is all, active or inactive. */
List<Map<String, Object>> retrieveCharges(Long accountId, String chargeStatus);
Map<String, Object> retrieveCharge(Long accountId, Long accountChargeId);
/** Template for adding a charge: the active current-account charges in the account's currency. */
Map<String, Object> retrieveChargeTemplate(Long accountId);
List<Map<String, Object>> retrieveAll(); List<Map<String, Object>> retrieveAll();
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName); List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName);
+2
View File
@@ -81,6 +81,8 @@ dependencies {
'org.apache.avro:avro', 'org.apache.avro:avro',
'jakarta.ws.rs:jakarta.ws.rs-api', 'jakarta.ws.rs:jakarta.ws.rs-api',
'org.glassfish.jersey.media:jersey-media-multipart', 'org.glassfish.jersey.media:jersey-media-multipart',
// Server-Sent Events (SseEventSink, Sse): the Approvals badge is pushed to the screen over one.
'org.glassfish.jersey.media:jersey-media-sse',
'org.springframework:spring-jms', 'org.springframework:spring-jms',
'org.springframework:spring-context-support', 'org.springframework:spring-context-support',
@@ -0,0 +1,197 @@
/**
* 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.interbranch.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Tells a person looking at one ledger transaction whether it is one branch's part of a transfer between branches.
*
* <p>
* A transfer between accounts at different branches is posted as one ledger transaction per branch, joined through the
* Liability Transfer account. Looking at either part on its own shows only two ledger lines, with nothing to say it has
* another part in another branch. The bank transfer log records the ledger ids of every part, so given any one of them
* this reports the whole transfer: who it moved money from and to, in which branches, and who raised and approved it.
*/
@Path("/v1/accounting/interbranchtransfers")
@Component
@RequiredArgsConstructor
@Produces(MediaType.APPLICATION_JSON)
public class InterBranchTransferApiResource {
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
@Path("{transactionId}")
public String retrieve(@PathParam("transactionId") final String transactionId) {
this.context.authenticatedUser().validateHasReadPermission("JOURNALENTRY");
// Ledger transaction ids are short alphanumeric strings; anything else cannot be one.
if (transactionId == null || !transactionId.matches("[A-Za-z0-9_-]{1,50}")) {
return notInterBranch();
}
final List<Map<String, Object>> logs = this.jdbcTemplate.queryForList(
"SELECT voucher_no, total_amount, gl_txn_id, debit_entries, credit_entries, status FROM m_bank_transfer_log "
+ "WHERE (',' || gl_txn_id || ',') LIKE ? ORDER BY id DESC LIMIT 1",
"%," + transactionId + ",%");
if (logs.isEmpty()) {
return notInterBranch();
}
final Map<String, Object> log = logs.get(0);
final List<Map<String, Object>> from = legsOf(log.get("debit_entries"));
final List<Map<String, Object>> to = legsOf(log.get("credit_entries"));
final Set<Object> branchIds = new LinkedHashSet<>();
for (final Map<String, Object> leg : from) {
branchIds.add(leg.get("branchId"));
}
for (final Map<String, Object> leg : to) {
branchIds.add(leg.get("branchId"));
}
branchIds.remove(null);
if (branchIds.size() < 2) {
return notInterBranch();
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("interBranch", true);
out.put("voucherNo", log.get("voucher_no"));
out.put("amount", log.get("total_amount"));
out.put("status", log.get("status"));
out.put("thisTransactionId", transactionId);
out.put("from", from);
out.put("to", to);
out.put("parts", partsOf(String.valueOf(log.get("gl_txn_id"))));
// Raised and approved by: the clerk's request, when the transfer went through approval.
final List<Map<String, Object>> requests = this.jdbcTemplate.queryForList(
"SELECT clerk_name, manager_name, submitted_at, actioned_at FROM m_clerk_disbursement_request WHERE voucher_no = ? "
+ "ORDER BY id DESC LIMIT 1",
log.get("voucher_no"));
if (!requests.isEmpty()) {
final Map<String, Object> request = requests.get(0);
out.put("raisedBy", request.get("clerk_name"));
out.put("raisedAt", isoOf(request.get("submitted_at")));
out.put("approvedBy", request.get("manager_name"));
out.put("approvedAt", isoOf(request.get("actioned_at")));
}
return this.gson.toJson(out);
}
private String notInterBranch() {
return "{\"interBranch\":false}";
}
/** Each account on one side of the transfer, with the branch its books are in. */
private List<Map<String, Object>> legsOf(final Object storedJson) {
final List<Map<String, Object>> legs = new ArrayList<>();
if (storedJson == null) {
return legs;
}
final JsonArray entries = this.gson.fromJson(String.valueOf(storedJson), JsonArray.class);
for (final JsonElement element : entries) {
final JsonObject entry = element.getAsJsonObject();
final Map<String, Object> leg = new LinkedHashMap<>();
leg.put("accountNo", text(entry, "accountNo"));
leg.put("accountName", text(entry, "accountName"));
leg.put("amount", text(entry, "amount"));
final Long accountId = entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()
? entry.get("savingsAccountId").getAsLong()
: null;
final Map<String, Object> branch = accountId == null ? null : branchOfAccount(text(entry, "accountType"), accountId);
leg.put("branchId", branch == null ? null : ((Number) branch.get("id")).longValue());
leg.put("branchName", branch == null ? null : branch.get("name"));
legs.add(leg);
}
return legs;
}
/** One entry per ledger transaction of the transfer, with the branch it was posted in. */
private List<Map<String, Object>> partsOf(final String glTxnIds) {
final List<Map<String, Object>> parts = new ArrayList<>();
for (final String id : glTxnIds.split(",")) {
final String ledgerId = id.trim();
if (ledgerId.isEmpty()) {
continue;
}
final List<Map<String, Object>> offices = this.jdbcTemplate.queryForList(
"SELECT DISTINCT o.id, o.name FROM acc_gl_journal_entry je JOIN m_office o ON o.id = je.office_id "
+ "WHERE je.transaction_id = ?",
ledgerId);
final Map<String, Object> part = new LinkedHashMap<>();
part.put("transactionId", ledgerId);
part.put("branchId", offices.isEmpty() ? null : ((Number) offices.get(0).get("id")).longValue());
part.put("branchName", offices.isEmpty() ? null : offices.get(0).get("name"));
parts.add(part);
}
return parts;
}
/**
* The branch an account's books are in: its own office when it was opened at another branch than the client's,
* otherwise the client's (or group's). A current account is its own table with its own ids.
*/
private Map<String, Object> branchOfAccount(final String accountType, final Long accountId) {
final String sql = "CURRENT".equalsIgnoreCase(accountType)
? "SELECT o.id, o.name FROM m_current_account ca LEFT JOIN m_client c ON c.id = ca.client_id "
+ "JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id) WHERE ca.id = ?"
: "SELECT o.id, o.name FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id "
+ "LEFT JOIN m_group g ON g.id = sa.group_id "
+ "JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id) WHERE sa.id = ?";
return this.jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(null);
}
private static String text(final JsonObject object, final String name) {
final JsonElement element = object.get(name);
return element == null || element.isJsonNull() ? null : element.getAsString();
}
private static String isoOf(final Object value) {
if (value == null) {
return null;
}
if (value instanceof java.sql.Timestamp) {
return ((java.sql.Timestamp) value).toLocalDateTime().toString();
}
return String.valueOf(value).replace(' ', 'T');
}
}
@@ -0,0 +1,162 @@
/**
* 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.interbranch.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.validation.GLAccountTransactionGuard;
import org.apache.fineract.accounting.journalentry.service.LedgerTransactionId;
import org.apache.fineract.accounting.journalentry.service.LedgerTxnPrefix;
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.Service;
/**
* Keeps a branch's cash ledger true when a cashier takes or pays cash for an account held at another branch.
*
* <p>
* A savings deposit or withdrawal always posts its ledger entries in the branch the account is held at, because that is
* where the account's books are. The cash itself, though, is in the drawer of whichever cashier handled it. When that
* cashier sits at a different branch, the account's branch would show cash it never received (or paid), and the
* cashier's branch would hold cash its ledger does not know about.
*
* <p>
* This moves the cash leg to the cashier's branch, through the Liability Transfer account, exactly as a transfer
* between two branches' accounts is booked: the account's branch reverses the cash line against Liability Transfer, and
* the cashier's branch books the same cash line against it. Each branch's books balance on their own and the bank's
* total does not change. A cashier at the account's own branch, or a user who is not a cashier, changes nothing.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class InterBranchCashService {
private static final int FINANCIAL_ACTIVITY_LIABILITY_TRANSFER = 200;
private static final int CREDIT = 1;
private static final int DEBIT = 2;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
/**
* @param savingsTransactionId
* the deposit or withdrawal just posted
* @param accountOfficeId
* the branch the account is held at, where that posting was booked
* @param deposit
* true for cash received, false for cash paid out
*/
public void moveCashLegToCashierBranch(final Long savingsTransactionId, final Long accountOfficeId, final String accountNo,
final BigDecimal amount, final LocalDate transactionDate, final boolean deposit) {
final AppUser user = this.context.authenticatedUser();
if (user.getOffice() == null || accountOfficeId == null || savingsTransactionId == null) {
return;
}
if (!isCashier(user.getId())) {
return;
}
moveCashLegToBranch(savingsTransactionId, accountOfficeId, user.getOffice().getId(), accountNo, amount, transactionDate, deposit);
}
/**
* Same as {@link #moveCashLegToCashierBranch}, for a posting made by someone other than the cashier who handled the
* cash, such as a manager approving a deposit a cashier took earlier. The branch the cash is in is given.
*/
public void moveCashLegToBranch(final Long savingsTransactionId, final Long accountOfficeId, final Long cashierOfficeId,
final String accountNo, final BigDecimal amount, final LocalDate transactionDate, final boolean deposit) {
final AppUser user = this.context.authenticatedUser();
if (cashierOfficeId == null || accountOfficeId == null || savingsTransactionId == null
|| cashierOfficeId.equals(accountOfficeId)) {
return;
}
// The cash line of the posting just made: the debit of a deposit, the credit of a withdrawal.
final List<Map<String, Object>> cashLines = this.jdbcTemplate.queryForList(
"SELECT account_id, currency_code FROM acc_gl_journal_entry WHERE savings_transaction_id = ? AND type_enum = ? "
+ "AND amount = ? AND reversed = false ORDER BY id DESC",
savingsTransactionId, deposit ? DEBIT : CREDIT, amount);
if (cashLines.isEmpty()) {
log.warn("No cash line found for savings transaction {}; its cash was not moved to the cashier's branch",
savingsTransactionId);
return;
}
final Long cashGlId = ((Number) cashLines.get(0).get("account_id")).longValue();
final String currencyCode = String.valueOf(cashLines.get(0).get("currency_code"));
final Long suspenseGlId = liabilityTransferGlId();
final String note = (deposit ? "Inter-branch cash deposit" : "Inter-branch cash withdrawal") + " | A/C " + accountNo
+ " | savings txn " + savingsTransactionId + " | cashier " + user.getUsername() + " | via Liability Transfer";
// Account's branch: take the cash line back out. A deposit's was Dr cash, so Cr cash; a withdrawal's was Cr cash.
post(accountOfficeId, cashGlId, deposit ? CREDIT : DEBIT, suspenseGlId, deposit ? DEBIT : CREDIT, amount, currencyCode,
transactionDate, user.getId(), note);
// Cashier's branch: the cash really moved here.
post(cashierOfficeId, cashGlId, deposit ? DEBIT : CREDIT, suspenseGlId, deposit ? CREDIT : DEBIT, amount, currencyCode,
transactionDate, user.getId(), note);
}
private boolean isCashier(final Long userId) {
final Integer count = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cashiers mc JOIN m_staff ms ON ms.id = mc.staff_id "
+ "JOIN m_appuser au ON au.staff_id = ms.id WHERE au.id = ?", Integer.class, userId);
return count != null && count > 0;
}
private Long liabilityTransferGlId() {
final List<Long> ids = this.jdbcTemplate.queryForList(
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
FINANCIAL_ACTIVITY_LIABILITY_TRANSFER);
if (ids.isEmpty()) {
throw new IllegalStateException("A cash deposit or withdrawal for another branch's account needs the Liability Transfer "
+ "account mapped under Accounting > Financial Activity Mappings.");
}
return ids.get(0);
}
/** One balanced entry in one branch: the cash line and its Liability Transfer counterpart. */
private void post(final Long officeId, final Long cashGlId, final int cashType, final Long suspenseGlId, final int suspenseType,
final BigDecimal amount, final String currencyCode, final LocalDate entryDate, final long userId, final String note) {
final String txnId = LedgerTransactionId.next(this.jdbcTemplate, LedgerTxnPrefix.INTER_BRANCH_CASH);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
insert(officeId, cashGlId, cashType, amount, txnId, entryDate, userId, now, currencyCode, note);
insert(officeId, suspenseGlId, suspenseType, amount, txnId, entryDate, userId, now, currencyCode, note);
}
private void insert(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) {
GLAccountTransactionGuard.assertPostable(this.jdbcTemplate, glAccountId);
final java.time.OffsetDateTime utc = now.atZone(DateUtils.getDateTimeZoneOfTenant()).toOffsetDateTime()
.withOffsetSameInstant(ZoneOffset.UTC);
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, 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), utc, utc);
}
}
@@ -1570,9 +1570,76 @@ public class AccountingProcessorHelper {
return mapping == null ? null : mapping.getGlAccount(); return mapping == null ? null : mapping.getGlAccount();
} }
/**
* The clearing account for the savings-to-savings account transfer running on this thread, taken from a product's
* own Transfers In Suspense mapping (see {@link #useProductTransferSuspense}). Null outside such a transfer.
*/
private static final ThreadLocal<GLAccount> TRANSFER_CLEARING_ACCOUNT = new ThreadLocal<>();
/**
* Makes both legs of a savings-to-savings account transfer (savings to RD by auto debit, savings to savings, a
* deposit paying out) clear through the Transfers In Suspense account set on the product's Accounting tab, rather
* than the organisation-wide Liability Transfer financial activity.
* <p>
* The receiving product's mapping is used first, then the paying product's: a savings product screen does not
* offer Transfers In Suspense, so for savings to RD it is the RD product's mapping. Both legs use the same account,
* so it nets to nil. Where neither product has one, nothing is set and the transfer clears through Liability
* Transfer as before.
* <p>
* Must be paired with {@link #clearProductTransferSuspense()} in a finally block.
*
* @return true when a product mapping was found and is now in use
*/
public boolean useProductTransferSuspense(final Long toSavingsProductId, final Long fromSavingsProductId) {
final int transfersSuspense = CashAccountsForSavings.TRANSFERS_SUSPENSE.getValue();
GLAccount account = optionalLinkedGLAccountForSavingsProduct(toSavingsProductId, transfersSuspense, null);
if (account == null) {
account = optionalLinkedGLAccountForSavingsProduct(fromSavingsProductId, transfersSuspense, null);
}
if (account == null) {
TRANSFER_CLEARING_ACCOUNT.remove();
return false;
}
TRANSFER_CLEARING_ACCOUNT.set(account);
return true;
}
public void clearProductTransferSuspense() {
TRANSFER_CLEARING_ACCOUNT.remove();
}
/**
* For undoing one leg of an account transfer: reverses through the clearing account that leg was actually posted
* to, whether a product's Transfers In Suspense or the Liability Transfer financial activity. A reversal works the
* accounts out again rather than copying the original rows, so without this a transfer posted to a product's
* suspense account would be reversed against Liability Transfer and leave both accounts open.
* <p>
* The clearing row is the credit of a withdrawal and the debit of a deposit (the largest, should a fee sit beside
* it). Nothing is set where the leg has no journal entries. Must be paired with
* {@link #clearProductTransferSuspense()} in a finally block.
*/
public void useClearingAccountPostedFor(final Long savingsTransactionId, final boolean isWithdrawal) {
TRANSFER_CLEARING_ACCOUNT.remove();
if (savingsTransactionId == null) {
return;
}
final List<Long> found = jdbcTemplate.queryForList("""
SELECT account_id FROM acc_gl_journal_entry
WHERE savings_transaction_id = ? AND type_enum = ? AND reversed = false
ORDER BY amount DESC, id
LIMIT 1
""", Long.class, savingsTransactionId, isWithdrawal ? JournalEntryType.CREDIT.getValue() : JournalEntryType.DEBIT.getValue());
if (!found.isEmpty()) {
glAccountRepository.findById(found.get(0)).ifPresent(TRANSFER_CLEARING_ACCOUNT::set);
}
}
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId, private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
final Long paymentTypeId) { final Long paymentTypeId) {
GLAccount glAccount; GLAccount glAccount;
if (accountMappingTypeId == FinancialActivity.LIABILITY_TRANSFER.getValue() && TRANSFER_CLEARING_ACCOUNT.get() != null) {
return TRANSFER_CLEARING_ACCOUNT.get();
}
if (isOrganizationAccount(accountMappingTypeId)) { if (isOrganizationAccount(accountMappingTypeId)) {
FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository
.findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId); .findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId);
@@ -74,12 +74,16 @@ public final class LedgerTxnPrefix {
public static final String CASH_MIGRATION = "CSHMIG"; public static final String CASH_MIGRATION = "CSHMIG";
/** Rounding difference on a clerk's day closure. */ /** Rounding difference on a clerk's day closure. */
public static final String CLOSURE_ROUNDING = "CLSRND"; public static final String CLOSURE_ROUNDING = "CLSRND";
/** Cash line of a deposit or withdrawal moved to the cashier's branch, when the account is held at another. */
public static final String INTER_BRANCH_CASH = "IBCASH";
// ── Year end ────────────────────────────────────────────────────────────── // ── Year end ──────────────────────────────────────────────────────────────
/** Year-end transfer of income into the profit account. */ /** Year-end transfer of income into the profit account. */
public static final String YEAR_END_PROFIT = "YEPFT"; public static final String YEAR_END_PROFIT = "YEPFT";
/** Year-end closure of an income or expense account. */ /** Year-end closure of an income or expense account. */
public static final String YEAR_END_CLOSE = "YECLS"; public static final String YEAR_END_CLOSE = "YECLS";
/** Appropriation of a closed year's profit into the reserve funds. */
public static final String PROFIT_APPROPRIATION = "PRAPP";
// ── Savings ─────────────────────────────────────────────────────────────── // ── Savings ───────────────────────────────────────────────────────────────
/** Pooled savings interest posting for one product and date. */ /** Pooled savings interest posting for one product and date. */
@@ -366,7 +366,7 @@ public class IncomeProfitTransferService {
return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class); return jdbcTemplate.queryForObject("SELECT id FROM m_office ORDER BY hierarchy LIMIT 1", Long.class);
} }
private String resolveCurrency(final Long glAccountId) { public String resolveCurrency(final Long glAccountId) {
try { try {
final String code = jdbcTemplate.queryForObject( final String code = jdbcTemplate.queryForObject(
"SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class, "SELECT currency_code FROM acc_gl_journal_entry WHERE account_id = ? ORDER BY id DESC LIMIT 1", String.class,
@@ -377,7 +377,8 @@ public class IncomeProfitTransferService {
} }
} }
private void insertEntry(final Long officeId, final Long glAccountId, final int typeEnum, final BigDecimal amount, final String txnId, /** Writes one journal entry leg. Shared with the profit appropriation posting so both year-end steps post alike. */
public 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) { final LocalDate entryDate, final Long userId, final LocalDateTime now, final String currencyCode, final String description) {
GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId); GLAccountTransactionGuard.assertPostable(jdbcTemplate, glAccountId);
jdbcTemplate.update( jdbcTemplate.update(
@@ -238,6 +238,11 @@ public class SecurityConfig {
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_LOANTRANSACTIONNOTE") .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_LOANTRANSACTIONNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/savings/*/notes")) .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/savings/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE") .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE")
// current accounts are deposit accounts: their notes use the savings-note permissions
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currentaccounts/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/currentaccounts/*/notes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/groups/*/notes")) .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/groups/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_GROUPNOTE") .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_READ, "READ_GROUPNOTE")
// notes: create // notes: create
@@ -249,6 +254,8 @@ public class SecurityConfig {
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_LOANTRANSACTIONNOTE") .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_LOANTRANSACTIONNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/savings/*/notes")) .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/savings/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SAVINGNOTE") .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/currentaccounts/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/groups/*/notes")) .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/groups/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_GROUPNOTE") .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "CREATE_GROUPNOTE")
// notes: update // notes: update
@@ -260,6 +267,8 @@ public class SecurityConfig {
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_LOANTRANSACTIONNOTE") .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_LOANTRANSACTIONNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/savings/*/notes")) .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/savings/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SAVINGNOTE") .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/currentaccounts/*/notes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/groups/*/notes")) .requestMatchers(API_MATCHER.matcher(HttpMethod.PUT, "/api/*/groups/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_GROUPNOTE") .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "UPDATE_GROUPNOTE")
// notes: delete // notes: delete
@@ -271,6 +280,8 @@ public class SecurityConfig {
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_LOANTRANSACTIONNOTE") .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_LOANTRANSACTIONNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/savings/*/notes")) .requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/savings/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_SAVINGNOTE") .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/currentaccounts/*/notes/*"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_SAVINGNOTE")
.requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/groups/*/notes")) .requestMatchers(API_MATCHER.matcher(HttpMethod.DELETE, "/api/*/groups/*/notes"))
.hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_GROUPNOTE") .hasAnyAuthority(ALL_FUNCTIONS, ALL_FUNCTIONS_WRITE, "DELETE_GROUPNOTE")
// document: clients // document: clients
@@ -303,9 +303,13 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
+ "join m_savings_product p on p.id = a.product_id " + "join m_savings_product p on p.id = a.product_id "
+ "where a.account_no = ? and a.status_enum = 300"; + "where a.account_no = ? and a.status_enum = 300";
/** The product DDS opens customers' scheme accounts under - see 0492. */ /**
private static final String DDS_DEPOSIT_PRODUCT_SQL = "select value from c_configuration " * Whether a savings account is a DDS customer's scheme account - one a DDS party is linked to.
+ "where name = 'dds-deposit-product' and enabled = true"; * Read off the parties themselves rather than a configured product id: what makes an account a
* scheme account is that a scheme runs on it, whatever product it was opened under.
*/
private static final String DDS_SCHEME_ACCOUNT_SQL = "select count(*) from m_dds_party p "
+ "join m_savings_account a on a.id = p.savings_account_id where a.account_no = ?";
private static final int SAVINGS_DEPOSIT_TYPE = 100; private static final int SAVINGS_DEPOSIT_TYPE = 100;
@@ -353,9 +357,8 @@ public class AgentWritePlatformServiceJpaRepositoryImpl implements AgentWritePla
// A DDS scheme account is a savings account too, so the check above lets it through - but it // A DDS scheme account is a savings account too, so the check above lets it through - but it
// belongs to a customer's scheme, and commission paid into it would be counted as that // belongs to a customer's scheme, and commission paid into it would be counted as that
// customer's deposits by every DDS interest run from then on. // customer's deposits by every DDS interest run from then on.
final Long productId = ((Number) account.get("product_id")).longValue(); final Integer schemes = this.jdbcTemplate.queryForObject(DDS_SCHEME_ACCOUNT_SQL, Integer.class, accountNo); // NOSONAR
final List<Long> ddsProduct = this.jdbcTemplate.queryForList(DDS_DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR if (schemes != null && schemes > 0) {
if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) {
throw new AgentSavingsAccountNotOperativeException(accountNo, "DDS scheme"); throw new AgentSavingsAccountNotOperativeException(accountNo, "DDS scheme");
} }
} }
@@ -44,6 +44,8 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
// A List, not a Set: isOneOfTheseStringValues only lowercases both sides in its List overload. // A List, not a Set: isOneOfTheseStringValues only lowercases both sides in its List overload.
private static final List<String> SUPPORTED_SLAB_TYPES = List.of("ACCOUNT", "COMMISSION"); private static final List<String> SUPPORTED_SLAB_TYPES = List.of("ACCOUNT", "COMMISSION");
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("slabs", "dateFormat", "locale")); private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("slabs", "dateFormat", "locale"));
private static final Set<String> SUPPORTED_UPDATE_PARAMETERS = new HashSet<>( private static final Set<String> SUPPORTED_UPDATE_PARAMETERS = new HashSet<>(
@@ -93,7 +95,7 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
v.reset().parameterAtIndexArray("slabTo", i).value(extractBigDecimal(slabJson, "slabTo")).notNull() v.reset().parameterAtIndexArray("slabTo", i).value(extractBigDecimal(slabJson, "slabTo")).notNull()
.zeroOrPositiveAmount(); .zeroOrPositiveAmount();
v.reset().parameterAtIndexArray("commissionPercent", i).value(extractBigDecimal(slabJson, "commissionPercent")) v.reset().parameterAtIndexArray("commissionPercent", i).value(extractBigDecimal(slabJson, "commissionPercent"))
.notNull().zeroOrPositiveAmount(); .notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
} }
} }
@@ -134,7 +136,7 @@ public final class CommissionSlabCommandFromApiJsonDeserializer {
if (this.fromApiJsonHelper.parameterExists("commissionPercent", element)) { if (this.fromApiJsonHelper.parameterExists("commissionPercent", element)) {
v.reset().parameter("commissionPercent") v.reset().parameter("commissionPercent")
.value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("commissionPercent", element)).notNull() .value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("commissionPercent", element)).notNull()
.zeroOrPositiveAmount(); .zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
} }
throwExceptionIfValidationWarningsExist(dataValidationErrors); throwExceptionIfValidationWarningsExist(dataValidationErrors);
@@ -21,16 +21,21 @@ package org.apache.fineract.organisation.commissionslab.service;
import com.google.gson.JsonArray; import com.google.gson.JsonArray;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import jakarta.persistence.PersistenceException; import jakarta.persistence.PersistenceException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler; import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab; import org.apache.fineract.organisation.commissionslab.domain.CommissionSlab;
import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository; import org.apache.fineract.organisation.commissionslab.domain.CommissionSlabRepository;
import org.apache.fineract.organisation.commissionslab.exception.CommissionSlabNotFoundException; import org.apache.fineract.organisation.commissionslab.exception.CommissionSlabNotFoundException;
@@ -68,6 +73,14 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
slabs.add(slab); slabs.add(slab);
} }
// The chart each type will have once these are added - what is stored plus this request -
// must still read as one unbroken scale from 0.
for (final String type : slabs.stream().map(CommissionSlab::getSlabType).distinct().toList()) {
final List<CommissionSlab> chart = new ArrayList<>(this.commissionSlabRepository.findByType(type));
chart.addAll(slabs.stream().filter(s -> s.getSlabType().equals(type)).toList());
rejectBrokenChart(type, chart);
}
this.commissionSlabRepository.saveAllAndFlush(slabs); this.commissionSlabRepository.saveAllAndFlush(slabs);
return new CommandProcessingResultBuilder() // return new CommandProcessingResultBuilder() //
@@ -93,6 +106,9 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
final CommissionSlab slab = this.commissionSlabRepository.findById(slabId) final CommissionSlab slab = this.commissionSlabRepository.findById(slabId)
.orElseThrow(() -> new CommissionSlabNotFoundException(slabId)); .orElseThrow(() -> new CommissionSlabNotFoundException(slabId));
final String oldType = slab.getSlabType();
final BigDecimal oldFrom = slab.getSlabFrom();
final BigDecimal oldTo = slab.getSlabTo();
final Map<String, Object> changes = slab.update(command); final Map<String, Object> changes = slab.update(command);
// Re-checked after the change, against every other slab of its type. A correction can just as // Re-checked after the change, against every other slab of its type. A correction can just as
@@ -101,8 +117,38 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
if (!changes.isEmpty()) { if (!changes.isEmpty()) {
final List<CommissionSlab> others = this.commissionSlabRepository.findByType(slab.getSlabType()).stream() final List<CommissionSlab> others = this.commissionSlabRepository.findByType(slab.getSlabType()).stream()
.filter(other -> !other.getId().equals(slab.getId())).toList(); .filter(other -> !other.getId().equals(slab.getId())).toList();
// A boundary moved within the same chart takes its neighbour with it, so the two slabs still
// meet a paisa apart and the chart stays unbroken: 10,000 -> 12,000 on this slab's To moves
// the next slab's From from 10,000.01 to 12,000.01.
final List<CommissionSlab> moved = new ArrayList<>();
if (oldType.equals(slab.getSlabType())) {
for (final CommissionSlab other : others) {
if (slab.getSlabTo().compareTo(oldTo) != 0 && other.getSlabFrom().compareTo(oldTo.add(PAISA)) == 0) {
other.startAt(slab.getSlabTo().add(PAISA));
moved.add(other);
}
if (slab.getSlabFrom().compareTo(oldFrom) != 0 && other.getSlabTo().compareTo(oldFrom.subtract(PAISA)) == 0) {
other.endAt(slab.getSlabFrom().subtract(PAISA));
moved.add(other);
}
}
}
rejectIfOverlapping(slab, others); rejectIfOverlapping(slab, others);
final List<CommissionSlab> chart = new ArrayList<>(others);
chart.add(slab);
rejectBrokenChart(slab.getSlabType(), chart);
if (!oldType.equals(slab.getSlabType())) {
// Moved to the other type: the chart it left must still be whole too.
rejectBrokenChart(oldType, this.commissionSlabRepository.findByType(oldType).stream()
.filter(other -> !other.getId().equals(slab.getId())).toList());
}
this.commissionSlabRepository.saveAll(moved);
this.commissionSlabRepository.saveAndFlush(slab); this.commissionSlabRepository.saveAndFlush(slab);
if (!moved.isEmpty()) {
changes.put("neighboursAdjusted", moved.size());
}
} }
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(slabId).with(changes).build(); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(slabId).with(changes).build();
@@ -116,6 +162,63 @@ public class CommissionSlabWritePlatformServiceJpaRepositoryImpl implements Comm
* explain it. Ranges are inclusive at both ends, so touching endpoints count as an overlap: a * explain it. Ranges are inclusive at both ends, so touching endpoints count as an overlap: a
* slab ending at 50000 and the next starting at 50000 both match exactly 50000. * slab ending at 50000 and the next starting at 50000 both match exactly 50000.
*/ */
/** Collections are rupees and paise, so consecutive slabs meet one paisa apart. */
private static final BigDecimal PAISA = new BigDecimal("0.01");
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
/**
* A chart the commission run can always read: every slab From <= To, a percentage of 0 to 100, money
* to the paisa, starting at 0, and each slab beginning exactly a paisa after the one before ends.
*
* The run matches from <= collection <= to and stops with "no commission slab" for an agent whose
* collection no slab covers - so a gap, or a chart starting above 0, is not a gentler setting, it is
* a run that fails. Refused here, when the chart is saved, rather than discovered on payday.
*/
private void rejectBrokenChart(final String type, final List<CommissionSlab> chart) {
final List<CommissionSlab> sorted = chart.stream().sorted(Comparator.comparing(CommissionSlab::getSlabFrom)).toList();
final List<ApiParameterError> errors = new ArrayList<>();
for (int i = 0; i < sorted.size(); i++) {
final CommissionSlab slab = sorted.get(i);
final String range = plain(slab.getSlabFrom()) + " - " + plain(slab.getSlabTo());
if (slab.getSlabFrom().compareTo(slab.getSlabTo()) > 0) {
errors.add(chartError("from.after.to", type + " slab " + range + ": From is more than To.", "slabFrom"));
}
if (slab.getSlabFrom().stripTrailingZeros().scale() > 2 || slab.getSlabTo().stripTrailingZeros().scale() > 2) {
errors.add(chartError("paise", type + " slab " + range + ": amounts can have at most two decimals (paise).", "slabFrom"));
}
if (slab.getCommissionPercent().signum() < 0 || slab.getCommissionPercent().compareTo(HUNDRED) > 0) {
errors.add(chartError("percent", type + " slab " + range + ": the percentage must be from 0 to 100.",
"commissionPercent"));
}
if (i == 0) {
if (slab.getSlabFrom().signum() != 0) {
errors.add(chartError("not.from.zero", type + " slabs must start at 0 - nothing covers 0 to "
+ plain(slab.getSlabFrom().subtract(PAISA)) + ".", "slabFrom"));
}
} else {
final BigDecimal expected = sorted.get(i - 1).getSlabTo().add(PAISA);
if (slab.getSlabFrom().compareTo(expected) > 0) {
errors.add(chartError("gap", type + " slabs leave a gap: nothing covers " + plain(expected) + " to "
+ plain(slab.getSlabFrom().subtract(PAISA)) + ". Start this slab at " + plain(expected) + ".",
"slabFrom"));
}
}
}
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("error.msg.commissionslab.chart.broken",
"The " + type + " commission chart would not cover every collection: " + errors.get(0).getDefaultUserMessage(),
errors);
}
}
private static ApiParameterError chartError(final String code, final String message, final String parameter) {
return ApiParameterError.parameterError("error.msg.commissionslab." + code, message, parameter);
}
private static String plain(final BigDecimal value) {
return value.setScale(2, RoundingMode.HALF_UP).toPlainString();
}
private void rejectIfOverlapping(final CommissionSlab candidate, final List<CommissionSlab> existing) { private void rejectIfOverlapping(final CommissionSlab candidate, final List<CommissionSlab> existing) {
for (final CommissionSlab other : existing) { for (final CommissionSlab other : existing) {
if (!other.getSlabType().equals(candidate.getSlabType())) { if (!other.getSlabType().equals(candidate.getSlabType())) {
@@ -144,14 +144,22 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsTransactionBooleanValues transactionBooleanValues = new SavingsTransactionBooleanValues(isAccountTransfer, final SavingsTransactionBooleanValues transactionBooleanValues = new SavingsTransactionBooleanValues(isAccountTransfer,
isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), isInterestTransfer, isWithdrawBalance); isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), isInterestTransfer, isWithdrawBalance);
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt,
transactionDate, transactionAmount, paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill);
final Long toSavingsId = command.longValueOfParameterNamed(toAccountIdParamName); final Long toSavingsId = command.longValueOfParameterNamed(toAccountIdParamName);
final SavingsAccount toSavingsAccount = this.savingsAccountAssembler.assembleFrom(toSavingsId, backdatedTxnsAllowedTill); final SavingsAccount toSavingsAccount = this.savingsAccountAssembler.assembleFrom(toSavingsId, backdatedTxnsAllowedTill);
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate, // As in transferFunds: both legs clear through the product's Transfers In Suspense where one is mapped.
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill); final SavingsAccountTransaction withdrawal;
final SavingsAccountTransaction deposit;
this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId());
try {
withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt, transactionDate, transactionAmount,
paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill);
deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate, transactionAmount,
paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
} finally {
this.accountingProcessorHelper.clearProductTransferSuspense();
}
applyToRecurringDepositSchedule(toSavingsAccount, deposit); applyToRecurringDepositSchedule(toSavingsAccount, deposit);
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) { if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
@@ -277,14 +285,27 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
if (accountTransfer.getToLoanTransaction() != null) { if (accountTransfer.getToLoanTransaction() != null) {
this.loanAccountDomainService.reverseTransfer(accountTransfer.getToLoanTransaction()); this.loanAccountDomainService.reverseTransfer(accountTransfer.getToLoanTransaction());
} }
// Each leg is reversed through the clearing account it was posted to (a product's Transfers In Suspense or
// Liability Transfer), so the reversal clears that same account.
if (accountTransfer.getFromTransaction() != null) { if (accountTransfer.getFromTransaction() != null) {
this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getFromTransaction().getId(), true);
try {
this.savingsAccountWritePlatformService.undoTransaction( this.savingsAccountWritePlatformService.undoTransaction(
accountTransfer.accountTransferDetails().fromSavingsAccount().getId(), accountTransfer.getFromTransaction().getId(), accountTransfer.accountTransferDetails().fromSavingsAccount().getId(),
true); accountTransfer.getFromTransaction().getId(), true);
} finally {
this.accountingProcessorHelper.clearProductTransferSuspense();
}
} }
if (accountTransfer.getToSavingsTransaction() != null) { if (accountTransfer.getToSavingsTransaction() != null) {
this.savingsAccountWritePlatformService.undoTransaction(accountTransfer.accountTransferDetails().toSavingsAccount().getId(), this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getToSavingsTransaction().getId(), false);
try {
this.savingsAccountWritePlatformService.undoTransaction(
accountTransfer.accountTransferDetails().toSavingsAccount().getId(),
accountTransfer.getToSavingsTransaction().getId(), true); accountTransfer.getToSavingsTransaction().getId(), true);
} finally {
this.accountingProcessorHelper.clearProductTransferSuspense();
}
} }
accountTransfer.reverse(); accountTransfer.reverse();
this.accountTransferRepository.save(accountTransfer); this.accountTransferRepository.save(accountTransfer);
@@ -407,13 +428,22 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
transactionDate = transactionDate.plusDays(1); transactionDate = transactionDate.plusDays(1);
} }
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, // Both legs clear through the Transfers In Suspense account on the product's Accounting tab (the receiving
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(), // product's, else the paying one's) instead of the organisation-wide Liability Transfer mapping.
accountTransferDTO.getPaymentDetail(), transactionBooleanValues, backdatedTxnsAllowedTill); final SavingsAccountTransaction withdrawal;
final SavingsAccountTransaction deposit;
this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId());
try {
withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, accountTransferDTO.getFmt(),
transactionDate, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
transactionBooleanValues, backdatedTxnsAllowedTill);
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, accountTransferDTO.getFmt(), transactionDate,
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(), isAccountTransfer,
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill); isRegularTransaction, backdatedTxnsAllowedTill);
} finally {
this.accountingProcessorHelper.clearProductTransferSuspense();
}
applyToRecurringDepositSchedule(toSavingsAccount, deposit); applyToRecurringDepositSchedule(toSavingsAccount, deposit);
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount, accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
@@ -361,9 +361,15 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and // 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 // 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. // 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("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo, ");
// The branch the account is held at: its own office when opened at another branch, else the client's or group's.
accountsSummary.append("acct_office.id as accountOfficeId, acct_office.name as accountOfficeName ");
accountsSummary.append("from m_savings_account sa "); accountsSummary.append("from m_savings_account sa ");
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id "); accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
accountsSummary.append("left join m_client acct_client on acct_client.id = sa.client_id ");
accountsSummary.append("left join m_group acct_group on acct_group.id = sa.group_id ");
accountsSummary.append(
"left join m_office acct_office on acct_office.id = coalesce(sa.office_id, acct_client.office_id, acct_group.office_id) ");
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.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("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 sbu on sbu.id = sa.submittedon_userid ");
@@ -464,9 +470,12 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
final String fdAccountNumber = rs.getString("fdAccountNumber"); final String fdAccountNumber = rs.getString("fdAccountNumber");
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo"); final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
final Long accountOfficeId = JdbcSupport.getLong(rs, "accountOfficeId");
final String accountOfficeName = rs.getString("accountOfficeName");
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency, return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus, accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo); lastActiveTransactionDate, fdAccountNumber, renewalCycleNo, accountOfficeId, accountOfficeName);
} }
} }
@@ -496,9 +505,13 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
sb.append("ca.currency_code as currencyCode, ca.currency_digits as currencyDigits, ca.currency_multiplesof as inMultiplesOf, "); sb.append("ca.currency_code as currencyCode, ca.currency_digits as currencyDigits, ca.currency_multiplesof as inMultiplesOf, ");
sb.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, "); sb.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
sb.append("curr.display_symbol as currencyDisplaySymbol, "); sb.append("curr.display_symbol as currencyDisplaySymbol, ");
sb.append("ca.product_id as productId, cp.name as productName, cp.short_name as shortProductName "); sb.append("ca.product_id as productId, cp.name as productName, cp.short_name as shortProductName, ");
// The branch the account is held at: its own office, else the client's.
sb.append("acct_office.id as accountOfficeId, acct_office.name as accountOfficeName ");
sb.append("from m_current_account ca "); sb.append("from m_current_account ca ");
sb.append("join m_current_product cp on cp.id = ca.product_id "); sb.append("join m_current_product cp on cp.id = ca.product_id ");
sb.append("left join m_client acct_client on acct_client.id = ca.client_id ");
sb.append("left join m_office acct_office on acct_office.id = coalesce(ca.office_id, acct_client.office_id) ");
sb.append("join m_currency curr on curr.code = ca.currency_code "); sb.append("join m_currency curr on curr.code = ca.currency_code ");
sb.append("left join m_appuser sbu on sbu.id = ca.submittedon_userid "); sb.append("left join m_appuser sbu on sbu.id = ca.submittedon_userid ");
sb.append("left join m_appuser rbu on rbu.id = ca.rejectedon_userid "); sb.append("left join m_appuser rbu on rbu.id = ca.rejectedon_userid ");
@@ -578,10 +591,13 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400); final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
final Long accountOfficeId = JdbcSupport.getLong(rs, "accountOfficeId");
final String accountOfficeName = rs.getString("accountOfficeName");
// Current accounts have no deposit term, so no FD renewal chain to report. // Current accounts have no deposit term, so no FD renewal chain to report.
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency, 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); null, null, accountOfficeId, accountOfficeName);
} }
} }
@@ -0,0 +1,672 @@
/**
* 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.almreturn.api;
import com.google.gson.Gson;
import jakarta.ws.rs.BadRequestException;
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.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
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;
/**
* The fixed-term part of the Structural Liquidity Statement: what the bank's customers' Fixed
* Deposits, Recurring Deposits and term loans add up to in each residual-maturity bucket, counted
* from an as-on date.
*
* <h3>How each figure is slotted</h3>
*
* <ul>
* <li><b>Fixed and Recurring Deposits</b> (outflow): the account balance, on the deposit's maturity
* date. RBI slots term deposits by residual maturity; accrued interest is a separate line of the
* statement ("Interest Payable"), so it is not added here. A deposit already at or past its
* maturity is an overdue liability and goes in the first bucket.</li>
* <li><b>Term loans</b> (inflow): each unpaid instalment's principal, on that instalment's due date -
* RBI asks for the interim cash flows, not the whole loan in one bucket. An instalment already due
* is overdue: one that fell due less than a month ago goes in the "recent" bucket and an older one
* in the "old" bucket. Both bucket positions are parameters, because they are the bank's
* Overdue Receivables rule and not something this code should decide.</li>
* <li><b>Not slotted by date</b>, reported on their own so they are not silently lost: NPA loans (RBI
* slots them by classification, not by instalment) and cash-credit / overdraft / demand loans (their
* stored schedule is a synthetic equal-instalment one, not a real repayment pattern).</li>
* </ul>
*
* <h3>What this does not cover</h3>
*
* The bank's own placements and borrowings - FDs with other banks, investments, borrowings - have no
* register in the system, only ledger balances, so there is nothing to slot by date. The response
* says so rather than leaving them out silently.
*
* <h3>As-on date</h3>
*
* Accounts are read in their state today. The as-on date only decides where a maturity falls
* relative to it; it does not rebuild what the books looked like on an earlier day.
*
* <h3>Buckets</h3>
*
* The bucket edges arrive from the caller, since the bank edits them. {@code buckets=14d,28d,3m,6m,1y,3y,5y}
* means the first bucket ends 14 days out, the second 28 days out, the third 3 calendar months out
* and so on, with a last bucket that has no end. Months and years are calendar ones.
*/
@Path("/v1/almreturn/maturity-breakdown")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class AlmMaturityBreakdownApiResource {
/** Same audience as the other RBI returns, so no separate permission is introduced for it. */
private static final String RESOURCE_NAME_FOR_PERMISSION = "OSSRETURN";
/** The bucket edges used when the caller names none: RBI's non-scheduled UCB structure. */
private static final String DEFAULT_BUCKETS = "14d,28d,3m,6m,1y,3y,5y";
private static final int FIXED_DEPOSIT = 200;
private static final int RECURRING_DEPOSIT = 300;
/** SavingsAccountStatusType ACTIVE and MATURED: a matured deposit is still a liability until closed or renewed. */
private static final int SAVINGS_ACTIVE = 300;
private static final int SAVINGS_MATURED = 800;
/** LoanStatus.ACTIVE. */
private static final int LOAN_ACTIVE = 300;
private static final Pattern EDGE = Pattern.compile("^(\\d{1,4})([dmy])$");
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final Gson gson = new Gson();
@GET
public String retrieve(@QueryParam("asOnDate") final String asOnDateParam, @QueryParam("officeId") final Long officeId,
@QueryParam("buckets") final String bucketsParam, @QueryParam("overdueRecentBucket") final Integer overdueRecentBucket,
@QueryParam("overdueOldBucket") final Integer overdueOldBucket) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final LocalDate asOn = parseDate(asOnDateParam);
final List<LocalDate> limits = parseLimits(asOn, bucketsParam == null || bucketsParam.isBlank() ? DEFAULT_BUCKETS : bucketsParam);
final int bucketCount = limits.size() + 1;
final int recent = clampBucket(overdueRecentBucket, 0, bucketCount);
final int old = clampBucket(overdueOldBucket, 2, bucketCount);
final String hierarchy = scopeHierarchy(officeId) + "%";
final List<Map<String, Object>> groups = new ArrayList<>();
groups.add(depositGroup("FD", "Fixed Deposits", FIXED_DEPOSIT, asOn, limits, hierarchy));
groups.add(depositGroup("RD", "Recurring Deposits", RECURRING_DEPOSIT, asOn, limits, hierarchy));
groups.add(termLoanGroup(asOn, limits, hierarchy, recent, old));
final Map<String, Object> response = new LinkedHashMap<>();
response.put("asOnDate", asOn.toString());
response.put("officeId", officeId);
response.put("bucketCount", bucketCount);
response.put("bucketEnds", limits.stream().map(LocalDate::toString).toList());
response.put("groups", groups);
response.put("notSlotted", List.of(npaLoans(hierarchy), revolvingLoans(hierarchy)));
response.put("notes", List.of(
"Deposits are slotted by their balance on the maturity date; accrued interest belongs to the Interest Payable line.",
"Term loans are slotted instalment by instalment (principal only); accrued interest belongs to the Interest Receivable line.",
"Accounts are read in their state today; the as-on date only decides where a maturity falls.",
"The bank's own FDs with other banks, investments and borrowings have no register, so they are not part of this breakdown."));
return this.gson.toJson(response);
}
// ─────────────────────────── Deposits ───────────────────────────
private Map<String, Object> depositGroup(final String key, final String label, final int depositType, final LocalDate asOn,
final List<LocalDate> limits, final String hierarchy) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT datp.maturity_date AS "d",
sa.status_enum AS "st",
COUNT(*) AS "n",
COALESCE(SUM(sa.account_balance_derived), 0) AS "amt"
FROM m_savings_account sa
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id)
WHERE sa.deposit_type_enum = ?
AND sa.status_enum IN (?, ?)
AND o.hierarchy LIKE ?
GROUP BY datp.maturity_date, sa.status_enum
""", depositType, SAVINGS_ACTIVE, SAVINGS_MATURED, hierarchy);
final BigDecimal[] buckets = zeros(limits.size() + 1);
long accounts = 0;
for (final Map<String, Object> row : rows) {
final long n = ((Number) row.get("n")).longValue();
final BigDecimal amount = (BigDecimal) row.get("amt");
accounts += n;
final Date maturity = (Date) row.get("d");
// A MATURED deposit is already past its date and still owed: the first bucket, whatever the date says.
final boolean matured = ((Number) row.get("st")).intValue() == SAVINGS_MATURED;
final int index = matured || maturity == null ? 0 : bucketIndex(asOn, maturity.toLocalDate(), limits);
buckets[index] = buckets[index].add(amount);
}
return group(key, label, "liability", accounts, buckets);
}
// ─────────────────────────── Loans ───────────────────────────
private Map<String, Object> termLoanGroup(final LocalDate asOn, final List<LocalDate> limits, final String hierarchy,
final int recentOverdueBucket, final int oldOverdueBucket) {
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT rs.duedate AS "d",
COALESCE(SUM(GREATEST(rs.principal_amount
- COALESCE(rs.principal_completed_derived, 0)
- COALESCE(rs.principal_writtenoff_derived, 0), 0)), 0) AS "amt"
FROM m_loan_repayment_schedule rs
JOIN m_loan l ON l.id = rs.loan_id
LEFT JOIN m_client c ON c.id = l.client_id
LEFT JOIN m_group g ON g.id = l.group_id
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
WHERE l.loan_status_id = ?
AND COALESCE(l.is_npa, false) = false
AND COALESCE(rs.completed_derived, false) = false
AND NOT EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext
WHERE ext.loan_product_id = l.product_id
AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc')
OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))
AND o.hierarchy LIKE ?
GROUP BY rs.duedate
""", LOAN_ACTIVE, hierarchy);
final BigDecimal[] buckets = zeros(limits.size() + 1);
final LocalDate recentCutoff = asOn.minusMonths(1);
for (final Map<String, Object> row : rows) {
final LocalDate due = ((Date) row.get("d")).toLocalDate();
final BigDecimal amount = (BigDecimal) row.get("amt");
final int index;
if (due.isAfter(asOn)) {
index = bucketIndex(asOn, due, limits);
} else {
// Already due and unpaid. Under a month overdue is still expected shortly; older is not.
index = due.isAfter(recentCutoff) ? recentOverdueBucket : oldOverdueBucket;
}
buckets[index] = buckets[index].add(amount);
}
final Long loans = this.jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM m_loan l
LEFT JOIN m_client c ON c.id = l.client_id
LEFT JOIN m_group g ON g.id = l.group_id
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
WHERE l.loan_status_id = ?
AND COALESCE(l.is_npa, false) = false
AND NOT EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext
WHERE ext.loan_product_id = l.product_id
AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc')
OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))
AND o.hierarchy LIKE ?
""", Long.class, LOAN_ACTIVE, hierarchy);
return group("LOAN", "Term Loans (principal instalments)", "asset", loans == null ? 0 : loans, buckets);
}
/** NPA loans: RBI slots them by classification (sub-standard / doubtful / loss), not by instalment date. */
private Map<String, Object> npaLoans(final String hierarchy) {
return outstanding("NPA", "NPA loans", """
SELECT COUNT(*) AS "n", COALESCE(SUM(l.principal_outstanding_derived), 0) AS "amt"
FROM m_loan l
LEFT JOIN m_client c ON c.id = l.client_id
LEFT JOIN m_group g ON g.id = l.group_id
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
WHERE l.loan_status_id = ? AND COALESCE(l.is_npa, false) = true AND o.hierarchy LIKE ?
""", "Slotted by classification (sub-standard, doubtful, loss), net of provision, not by instalment date.",
hierarchy);
}
/** CC / OD / demand loans: the stored schedule is synthetic, so the volatile/core split rule applies instead. */
private Map<String, Object> revolvingLoans(final String hierarchy) {
return outstanding("REVOLVING", "CC / OD / demand loans", """
SELECT COUNT(*) AS "n", COALESCE(SUM(l.principal_outstanding_derived), 0) AS "amt"
FROM m_loan l
LEFT JOIN m_client c ON c.id = l.client_id
LEFT JOIN m_group g ON g.id = l.group_id
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
WHERE l.loan_status_id = ?
AND COALESCE(l.is_npa, false) = false
AND EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext
WHERE ext.loan_product_id = l.product_id
AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc')
OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))
AND o.hierarchy LIKE ?
""", "Split into a volatile and a core share by the bank's own rule; the stored instalment schedule is not a real repayment pattern.",
hierarchy);
}
/** One count-and-total line for loans that are reported apart from the date buckets. */
private Map<String, Object> outstanding(final String key, final String label, final String sql, final String why,
final String hierarchy) {
final Map<String, Object> row = this.jdbcTemplate.queryForMap(sql, LOAN_ACTIVE, hierarchy);
final Map<String, Object> line = new LinkedHashMap<>();
line.put("key", key);
line.put("label", label);
line.put("side", "asset");
line.put("accounts", ((Number) row.get("n")).longValue());
line.put("outstanding", row.get("amt"));
line.put("why", why);
return line;
}
// ─────────────────────────── Picking accounts one by one ───────────────────────────
/** How many matches one search returns. */
private static final int SEARCH_LIMIT = 25;
/** How many accounts one report may cover. */
private static final int SELECTED_LIMIT = 200;
/** A loan product whose stored schedule is not a real repayment pattern: cash credit, overdraft, demand. */
private static final String REVOLVING_PRODUCT = "EXISTS (SELECT 1 FROM m_product_loan_advance_ext ext"
+ " WHERE ext.loan_product_id = l.product_id"
+ " AND (LOWER(ext.product_type_sub_group) IN ('cc', 'overdraft', 'kcc') OR LOWER(ext.facility_type) IN ('demand', 'overdraft')))";
/**
* Find fixed deposits, recurring deposits and loans by account number or customer name, so a few of
* them can be picked and broken down by days.
*
* @param kind
* FD, RD or LOAN to look in one kind only; empty to look in all three
*/
@GET
@Path("search")
public String search(@QueryParam("q") final String q, @QueryParam("kind") final String kind,
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
final String text = q == null ? "" : q.trim().toLowerCase();
final List<Map<String, Object>> results = new ArrayList<>();
if (text.length() >= 2) {
final String like = "%" + text + "%";
final String hierarchy = scopeHierarchy(officeId) + "%";
final String wanted = kind == null ? "" : kind.trim().toUpperCase();
if (wanted.isEmpty() || "FD".equals(wanted)) {
results.addAll(searchDeposits(FIXED_DEPOSIT, "FD", like, hierarchy));
}
if (wanted.isEmpty() || "RD".equals(wanted)) {
results.addAll(searchDeposits(RECURRING_DEPOSIT, "RD", like, hierarchy));
}
if (wanted.isEmpty() || "LOAN".equals(wanted)) {
results.addAll(searchLoans(like, hierarchy));
}
}
final Map<String, Object> response = new LinkedHashMap<>();
response.put("results", results);
return this.gson.toJson(response);
}
/**
* The day-wise breakdown of the accounts that were picked: each deposit as one row on its maturity
* date, each loan as one row per unpaid instalment on its due date, every row with the days to that
* date from the as-on date and the bucket it falls in.
*
* @param accountsParam
* the picked accounts as KIND:id pairs, for example FD:12,RD:5,LOAN:33
*/
@GET
@Path("selected")
public String selected(@QueryParam("accounts") final String accountsParam, @QueryParam("asOnDate") final String asOnDateParam,
@QueryParam("officeId") final Long officeId, @QueryParam("buckets") final String bucketsParam,
@QueryParam("overdueRecentBucket") final Integer overdueRecentBucket,
@QueryParam("overdueOldBucket") final Integer overdueOldBucket) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSION);
if (accountsParam == null || accountsParam.isBlank()) {
throw new BadRequestException("accounts is required, like FD:12,RD:5,LOAN:33");
}
final LocalDate asOn = parseDate(asOnDateParam);
final List<LocalDate> limits = parseLimits(asOn, bucketsParam == null || bucketsParam.isBlank() ? DEFAULT_BUCKETS : bucketsParam);
final int bucketCount = limits.size() + 1;
final String hierarchy = scopeHierarchy(officeId) + "%";
final List<Long> fixed = new ArrayList<>();
final List<Long> recurring = new ArrayList<>();
final List<Long> loans = new ArrayList<>();
for (final String token : accountsParam.split(",")) {
final String[] parts = token.trim().split(":");
if (parts.length != 2) {
throw new BadRequestException("accounts must look like FD:12,RD:5,LOAN:33");
}
final long id;
try {
id = Long.parseLong(parts[1].trim());
} catch (final NumberFormatException e) {
throw new BadRequestException("Account id '" + parts[1] + "' is not a number");
}
switch (parts[0].trim().toUpperCase()) {
case "FD" -> fixed.add(id);
case "RD" -> recurring.add(id);
case "LOAN" -> loans.add(id);
default -> throw new BadRequestException("Account kind '" + parts[0] + "' must be FD, RD or LOAN");
}
}
if (fixed.size() + recurring.size() + loans.size() > SELECTED_LIMIT) {
throw new BadRequestException("Pick at most " + SELECTED_LIMIT + " accounts at a time");
}
final List<Map<String, Object>> rows = new ArrayList<>();
if (!fixed.isEmpty()) {
rows.addAll(depositRows(FIXED_DEPOSIT, "FD", fixed, asOn, limits, hierarchy));
}
if (!recurring.isEmpty()) {
rows.addAll(depositRows(RECURRING_DEPOSIT, "RD", recurring, asOn, limits, hierarchy));
}
if (!loans.isEmpty()) {
rows.addAll(instalmentRows(loans, asOn, limits, hierarchy, clampBucket(overdueRecentBucket, 0, bucketCount),
clampBucket(overdueOldBucket, 2, bucketCount)));
}
// By date, undated rows last, then by account.
rows.sort(Comparator.comparing((Map<String, Object> r) -> String.valueOf(r.get("date")))
.thenComparing(r -> String.valueOf(r.get("accountNo"))));
BigDecimal total = BigDecimal.ZERO;
for (final Map<String, Object> row : rows) {
total = total.add((BigDecimal) row.get("amount"));
}
final Map<String, Object> response = new LinkedHashMap<>();
response.put("asOnDate", asOn.toString());
response.put("bucketCount", bucketCount);
response.put("count", rows.size());
response.put("total", total);
response.put("rows", rows);
return this.gson.toJson(response);
}
private List<Map<String, Object>> searchDeposits(final int depositType, final String kind, final String like,
final String hierarchy) {
final List<Map<String, Object>> found = this.jdbcTemplate.queryForList("""
SELECT sa.id AS "id",
sa.account_no AS "accountNo",
COALESCE(c.display_name, g.display_name) AS "customer",
sp.name AS "product",
datp.maturity_date AS "d",
COALESCE(sa.account_balance_derived, 0) AS "amt"
FROM m_savings_account sa
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id
JOIN m_savings_product sp ON sp.id = sa.product_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id)
WHERE sa.deposit_type_enum = ?
AND sa.status_enum IN (?, ?)
AND o.hierarchy LIKE ?
AND (LOWER(sa.account_no) LIKE ? OR LOWER(COALESCE(c.display_name, g.display_name, '')) LIKE ?)
ORDER BY sa.account_no
LIMIT ?
""", depositType, SAVINGS_ACTIVE, SAVINGS_MATURED, hierarchy, like, like, SEARCH_LIMIT);
return hits(found, kind);
}
private List<Map<String, Object>> searchLoans(final String like, final String hierarchy) {
final List<Map<String, Object>> found = this.jdbcTemplate.queryForList("""
SELECT l.id AS "id",
l.account_no AS "accountNo",
COALESCE(c.display_name, g.display_name) AS "customer",
lp.name AS "product",
COALESCE(l.principal_outstanding_derived, 0) AS "amt"
FROM m_loan l
JOIN m_product_loan lp ON lp.id = l.product_id
LEFT JOIN m_client c ON c.id = l.client_id
LEFT JOIN m_group g ON g.id = l.group_id
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
WHERE l.loan_status_id = ?
AND o.hierarchy LIKE ?
AND (LOWER(l.account_no) LIKE ? OR LOWER(COALESCE(c.display_name, g.display_name, '')) LIKE ?)
ORDER BY l.account_no
LIMIT ?
""", LOAN_ACTIVE, hierarchy, like, like, SEARCH_LIMIT);
return hits(found, "LOAN");
}
private static List<Map<String, Object>> hits(final List<Map<String, Object>> found, final String kind) {
final List<Map<String, Object>> rows = new ArrayList<>();
for (final Map<String, Object> r : found) {
final Date date = (Date) r.get("d");
final Map<String, Object> row = new LinkedHashMap<>();
row.put("kind", kind);
row.put("id", r.get("id"));
row.put("accountNo", r.get("accountNo"));
row.put("customer", r.get("customer"));
row.put("product", r.get("product"));
row.put("date", date == null ? null : date.toLocalDate().toString());
row.put("amount", r.get("amt"));
rows.add(row);
}
return rows;
}
private static String placeholders(final int count) {
return String.join(",", Collections.nCopies(count, "?"));
}
private List<Map<String, Object>> depositRows(final int depositType, final String label, final List<Long> ids,
final LocalDate asOn, final List<LocalDate> limits, final String hierarchy) {
final String sql = """
SELECT sa.id AS "id",
sa.account_no AS "accountNo",
COALESCE(c.display_name, g.display_name) AS "customer",
sp.name AS "product",
datp.maturity_date AS "d",
sa.status_enum AS "st",
COALESCE(sa.account_balance_derived, 0) AS "amt"
FROM m_savings_account sa
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id
JOIN m_savings_product sp ON sp.id = sa.product_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id)
WHERE sa.deposit_type_enum = ?
AND sa.status_enum IN (?, ?)
AND o.hierarchy LIKE ?
AND sa.id IN (%s)
ORDER BY datp.maturity_date, sa.account_no
""".formatted(placeholders(ids.size()));
final List<Object> args = new ArrayList<>(List.of(depositType, SAVINGS_ACTIVE, SAVINGS_MATURED, hierarchy));
args.addAll(ids);
final List<Map<String, Object>> rows = new ArrayList<>();
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
final Date maturity = (Date) r.get("d");
final boolean matured = ((Number) r.get("st")).intValue() == SAVINGS_MATURED;
final Map<String, Object> row = new LinkedHashMap<>();
row.put("id", r.get("id"));
row.put("accountNo", r.get("accountNo"));
row.put("customer", r.get("customer"));
row.put("product", r.get("product"));
row.put("kind", label);
row.put("date", maturity == null ? null : maturity.toLocalDate().toString());
row.put("days", maturity == null ? null : ChronoUnit.DAYS.between(asOn, maturity.toLocalDate()));
row.put("status", matured ? "MATURED" : "ACTIVE");
// Same rule as the totals: a matured deposit is overdue and sits in the first bucket.
row.put("bucket", matured || maturity == null ? 0 : bucketIndex(asOn, maturity.toLocalDate(), limits));
row.put("amount", r.get("amt"));
rows.add(row);
}
return rows;
}
/**
* The unpaid instalments of the picked loans. A picked loan is shown by its schedule whatever it is,
* with a note where the schedule is not the whole story (an NPA, or a cash credit / overdraft).
*/
private List<Map<String, Object>> instalmentRows(final List<Long> ids, final LocalDate asOn, final List<LocalDate> limits,
final String hierarchy, final int recentOverdueBucket, final int oldOverdueBucket) {
final String sql = """
SELECT l.id AS "id",
l.account_no AS "accountNo",
COALESCE(c.display_name, g.display_name) AS "customer",
lp.name AS "product",
rs.installment AS "installment",
rs.duedate AS "d",
GREATEST(rs.principal_amount
- COALESCE(rs.principal_completed_derived, 0)
- COALESCE(rs.principal_writtenoff_derived, 0), 0) AS "amt",
COALESCE(l.is_npa, false) AS "npa",
(%s) AS "revolving"
FROM m_loan_repayment_schedule rs
JOIN m_loan l ON l.id = rs.loan_id
JOIN m_product_loan lp ON lp.id = l.product_id
LEFT JOIN m_client c ON c.id = l.client_id
LEFT JOIN m_group g ON g.id = l.group_id
JOIN m_office o ON o.id = COALESCE(c.office_id, g.office_id)
WHERE l.loan_status_id = ?
AND COALESCE(rs.completed_derived, false) = false
AND GREATEST(rs.principal_amount
- COALESCE(rs.principal_completed_derived, 0)
- COALESCE(rs.principal_writtenoff_derived, 0), 0) > 0
AND o.hierarchy LIKE ?
AND l.id IN (%s)
ORDER BY rs.duedate, l.account_no
""".formatted(REVOLVING_PRODUCT, placeholders(ids.size()));
final List<Object> args = new ArrayList<>(List.of(LOAN_ACTIVE, hierarchy));
args.addAll(ids);
final LocalDate recentCutoff = asOn.minusMonths(1);
final List<Map<String, Object>> rows = new ArrayList<>();
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
final LocalDate due = ((Date) r.get("d")).toLocalDate();
final int bucket;
if (due.isAfter(asOn)) {
bucket = bucketIndex(asOn, due, limits);
} else {
bucket = due.isAfter(recentCutoff) ? recentOverdueBucket : oldOverdueBucket;
}
final boolean npa = Boolean.TRUE.equals(r.get("npa"));
final boolean revolving = Boolean.TRUE.equals(r.get("revolving"));
final Map<String, Object> row = new LinkedHashMap<>();
row.put("id", r.get("id"));
row.put("accountNo", r.get("accountNo"));
row.put("customer", r.get("customer"));
row.put("product", r.get("product"));
row.put("kind", "LOAN");
row.put("installment", r.get("installment"));
row.put("date", due.toString());
row.put("days", ChronoUnit.DAYS.between(asOn, due));
row.put("status", due.isAfter(asOn) ? "DUE LATER" : "OVERDUE");
row.put("bucket", bucket);
row.put("amount", r.get("amt"));
row.put("note", npa ? "NPA loan: RBI slots it by classification, not by instalment date"
: revolving ? "CC / OD loan: the stored schedule is not a real repayment pattern" : null);
rows.add(row);
}
return rows;
}
// ─────────────────────────── Helpers ───────────────────────────
private static Map<String, Object> group(final String key, final String label, final String side, final long accounts,
final BigDecimal[] buckets) {
BigDecimal total = BigDecimal.ZERO;
for (final BigDecimal b : buckets) {
total = total.add(b);
}
final Map<String, Object> group = new LinkedHashMap<>();
group.put("key", key);
group.put("label", label);
group.put("side", side);
group.put("accounts", accounts);
group.put("total", total);
group.put("buckets", List.of(buckets));
return group;
}
private static BigDecimal[] zeros(final int size) {
final BigDecimal[] values = new BigDecimal[size];
java.util.Arrays.fill(values, BigDecimal.ZERO);
return values;
}
/** Bucket of a maturity: due or overdue goes in the first; otherwise the first bucket whose end it does not pass. */
static int bucketIndex(final LocalDate asOn, final LocalDate maturity, final List<LocalDate> limits) {
if (!maturity.isAfter(asOn)) {
return 0;
}
for (int i = 0; i < limits.size(); i++) {
if (!maturity.isAfter(limits.get(i))) {
return i;
}
}
return limits.size();
}
/** "14d,28d,3m" to the dates those edges reach from the as-on date. Calendar months and years; edges must climb. */
private static List<LocalDate> parseLimits(final LocalDate asOn, final String spec) {
final List<LocalDate> limits = new ArrayList<>();
for (final String token : spec.split(",")) {
final Matcher m = EDGE.matcher(token.trim().toLowerCase());
if (!m.matches() || Integer.parseInt(m.group(1)) < 1) {
throw new BadRequestException("Bucket edge '" + token + "' is not like 14d, 3m or 1y");
}
final int n = Integer.parseInt(m.group(1));
final LocalDate limit = switch (m.group(2)) {
case "d" -> asOn.plusDays(n);
case "m" -> asOn.plusMonths(n);
default -> asOn.plusYears(n);
};
if (!limits.isEmpty() && !limit.isAfter(limits.get(limits.size() - 1))) {
throw new BadRequestException("Bucket edges must run in increasing order: " + spec);
}
limits.add(limit);
}
return limits;
}
private static int clampBucket(final Integer requested, final int fallback, final int bucketCount) {
final int value = requested == null ? fallback : requested;
return Math.max(0, Math.min(value, bucketCount - 1));
}
private static LocalDate parseDate(final String raw) {
if (raw == null || raw.isBlank()) {
return DateUtils.getBusinessLocalDate();
}
try {
return LocalDate.parse(raw);
} catch (final java.time.format.DateTimeParseException e) {
throw new BadRequestException("asOnDate must be yyyy-MM-dd");
}
}
/** The office tree a caller may read: their own, or a branch under it if they asked for one. */
private String scopeHierarchy(final Long officeId) {
final String own = this.context.authenticatedUser().getOffice().getHierarchy();
if (officeId == null) {
return own;
}
final List<String> asked = this.jdbcTemplate.queryForList("SELECT hierarchy FROM m_office WHERE id = ?", String.class, officeId);
return !asked.isEmpty() && asked.get(0).startsWith(own) ? asked.get(0) : own;
}
}
@@ -0,0 +1,153 @@
/**
* 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.aml.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import jakarta.ws.rs.DefaultValue;
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.math.RoundingMode;
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.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* How much came into, and went out of, each Cash Credit account over a window, set against what the customer declared
* and what the bank sanctioned.
*
* <p>
* Read straight from the posted current-account transactions, so it works today without the AML engine being fed those
* transactions. A Cash Credit account is a current account allowed to draw an overdraft; every active current account
* is listed, with that flag, so a plain current account is not hidden from view. Accounts are limited to the caller's
* branch hierarchy, and the busiest are listed first.
*/
@Path("/v1/aml/cc-tracking")
@Component
@RequiredArgsConstructor
@Produces(MediaType.APPLICATION_JSON)
public class AmlCcTrackingApiResource {
/** Current-account transaction types 1 and 2 are a deposit and a withdrawal, as on a savings account. */
private static final String SQL = """
SELECT ca.id AS account_id, ca.account_no, c.id AS client_id, c.display_name AS client_name,
o.name AS branch_name, ca.allow_overdraft, ca.overdraft_limit, ca.account_balance_derived,
COALESCE(NULLIF(c.monthly_turnover, 0), NULLIF(k.declared_annual_turnover, 0) / 12) AS declared_monthly_turnover,
COALESCE(SUM(t.amount) FILTER (WHERE t.transaction_type_enum = 1), 0) AS credits,
COALESCE(SUM(t.amount) FILTER (WHERE t.transaction_type_enum = 2), 0) AS debits,
COUNT(*) FILTER (WHERE t.transaction_type_enum = 1) AS credit_count,
COUNT(*) FILTER (WHERE t.transaction_type_enum = 2) AS debit_count,
COALESCE(SUM(t.amount) FILTER (WHERE t.transaction_type_enum = 1 AND pt.is_cash_payment), 0) AS cash_credits,
COALESCE(SUM(t.amount) FILTER (WHERE t.transaction_type_enum = 2 AND pt.is_cash_payment), 0) AS cash_debits,
MAX(t.transaction_date) AS last_txn_date
FROM m_current_account ca
LEFT JOIN m_client c ON c.id = ca.client_id
LEFT JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id)
LEFT JOIN m_client_kyc k ON k.client_id = c.id
LEFT JOIN m_current_account_transaction t ON t.current_account_id = ca.id AND t.is_reversed = false
AND t.transaction_type_enum IN (1, 2) AND t.transaction_date > ? AND t.transaction_date <= ?
LEFT JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
LEFT JOIN m_payment_type pt ON pt.id = pd.payment_type_id
WHERE ca.status_enum = 300
AND COALESCE(ca.office_id, c.office_id) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)
GROUP BY ca.id, c.id, o.id, k.id
ORDER BY SUM(t.amount) DESC NULLS LAST, ca.account_no
""";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@GET
public String retrieve(@QueryParam("days") @DefaultValue("30") final int days) {
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy();
final int window = Math.min(Math.max(days, 1), 366);
final LocalDate asOf = DateUtils.getBusinessLocalDate();
final LocalDate from = asOf.minusDays(window);
final List<Map<String, Object>> rows = new ArrayList<>();
int rank = 0;
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(SQL, java.sql.Date.valueOf(from),
java.sql.Date.valueOf(asOf), hierarchy + "%")) {
final BigDecimal credits = money(r.get("credits"));
final BigDecimal debits = money(r.get("debits"));
final BigDecimal limit = money(r.get("overdraft_limit"));
final BigDecimal balance = money(r.get("account_balance_derived"));
final BigDecimal declaredMonthly = r.get("declared_monthly_turnover") == null ? null : money(r.get("declared_monthly_turnover"));
// What the declared monthly turnover comes to over this window, so a 7-day look is not judged against a month.
final BigDecimal declaredForWindow = declaredMonthly == null ? null
: declaredMonthly.multiply(BigDecimal.valueOf(window)).divide(BigDecimal.valueOf(30), 2, RoundingMode.HALF_UP);
final Map<String, Object> row = new LinkedHashMap<>();
row.put("rank", ++rank);
row.put("accountId", r.get("account_id"));
row.put("accountNo", r.get("account_no"));
row.put("clientId", r.get("client_id"));
row.put("clientName", r.get("client_name"));
row.put("branchName", r.get("branch_name"));
row.put("isCc", Boolean.TRUE.equals(r.get("allow_overdraft")));
row.put("sanctionedLimit", limit);
row.put("balance", balance);
// How much of the sanctioned limit is drawn: the overdrawn part of the balance over the limit.
row.put("limitDrawnPercent", limit.signum() > 0 && balance.signum() < 0
? balance.negate().multiply(BigDecimal.valueOf(100)).divide(limit, 1, RoundingMode.HALF_UP) : null);
row.put("declaredMonthlyTurnover", declaredMonthly);
row.put("credits", credits);
row.put("debits", debits);
row.put("net", credits.subtract(debits));
row.put("creditCount", r.get("credit_count"));
row.put("debitCount", r.get("debit_count"));
row.put("cashCredits", money(r.get("cash_credits")));
row.put("cashDebits", money(r.get("cash_debits")));
row.put("cashCreditPercent", credits.signum() > 0
? money(r.get("cash_credits")).multiply(BigDecimal.valueOf(100)).divide(credits, 1, RoundingMode.HALF_UP) : null);
// Credits and debits as a multiple of what the customer said the business turns over in this window.
row.put("creditsVsDeclared", ratio(credits, declaredForWindow));
row.put("debitsVsDeclared", ratio(debits, declaredForWindow));
row.put("lastTxnDate", r.get("last_txn_date") == null ? null : r.get("last_txn_date").toString());
rows.add(row);
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("asOf", asOf.toString());
out.put("from", from.toString());
out.put("days", window);
out.put("accounts", rows);
return this.gson.toJson(out);
}
private static BigDecimal money(final Object value) {
return value == null ? BigDecimal.ZERO : new BigDecimal(value.toString());
}
private static BigDecimal ratio(final BigDecimal amount, final BigDecimal base) {
return base == null || base.signum() <= 0 ? null : amount.divide(base, 2, RoundingMode.HALF_UP);
}
}
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.aml.job;
import java.time.YearMonth; import java.time.YearMonth;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport; import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService; import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
import org.springframework.batch.core.StepContribution; import org.springframework.batch.core.StepContribution;
@@ -30,9 +31,8 @@ import org.springframework.batch.repeat.RepeatStatus;
/** /**
* Generates the CTR report for the previous calendar month as a {@code DRAFT} - mechanical * Generates the CTR report for the previous calendar month as a {@code DRAFT} - mechanical
* threshold aggregation only. Admin-triggered today (see {@code AmlCtrReportApiResource}); wiring * threshold aggregation only. Scheduled for 02:00 on the 1st (changelog 0606), and still available from the
* to a real monthly cron is a follow-up, matching the same "driven by an admin endpoint today" * admin endpoint (see {@code AmlCtrReportApiResource}) for a month run by hand.
* stance already used for {@code NeftBatchProcessorService}.
*/ */
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -42,7 +42,9 @@ public class AmlCtrReportBatchTasklet implements Tasklet {
@Override @Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
final YearMonth previousMonth = YearMonth.now().minusMonths(1); // The business date, not the wall clock: the month that has just ended is the bank's month, and the two dates
// are not always the same day.
final YearMonth previousMonth = YearMonth.from(DateUtils.getBusinessLocalDate()).minusMonths(1);
try { try {
final AmlCtrReport report = generationService.generateForMonth(previousMonth); final AmlCtrReport report = generationService.generateForMonth(previousMonth);
log.info("[Job:CTR_REPORT_GENERATION] generated CTR report for {} with {} line(s)", previousMonth, log.info("[Job:CTR_REPORT_GENERATION] generated CTR report for {} with {} line(s)", previousMonth,
@@ -0,0 +1,67 @@
package org.apache.fineract.portfolio.approvals.api;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.DefaultValue;
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.CacheControl;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.sse.Sse;
import jakarta.ws.rs.sse.SseEventSink;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsReadPlatformService;
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsStreamService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Component;
/**
* How many requests wait on every Approval Center tab, for the badge on each tab and the Total on the Approvals menu.
* Numbers only: opening a tab still loads that tab's own list. The counting is in
* {@link ApprovalCountsReadPlatformService}; the live stream is in {@link ApprovalCountsStreamService}.
*/
@Path("/v1/approvals/counts")
@Component
@RequiredArgsConstructor
@Produces({ MediaType.APPLICATION_JSON })
public class ApprovalCountsApiResource {
private final PlatformSecurityContext context;
private final ApprovalCountsReadPlatformService readService;
private final ApprovalCountsStreamService streamService;
/**
* The counts as they are now, answered at once. {@code fresh=true} skips the few seconds the last answer is held
* for. Screens that can stream use {@link #stream} instead; this is for a one-off read and as a fallback.
*/
@GET
public Response counts(@QueryParam("fresh") @DefaultValue("false") final boolean fresh) {
final AppUser user = this.context.authenticatedUser();
final CacheControl noStore = new CacheControl();
noStore.setNoStore(true);
return Response.ok(this.readService.retrieveHeld(user, fresh).toJson()).cacheControl(noStore).build();
}
/**
* Server-Sent Events: the connection stays open and a {@code counts} event is written the moment the numbers change,
* so a screen neither polls nor reloads. The first event carries the numbers as they stand.
*/
@GET
@Path("stream")
@Produces(MediaType.SERVER_SENT_EVENTS)
public void stream(@Context final SseEventSink sink, @Context final Sse sse, @Context final HttpServletResponse response) {
// Ask reverse proxies not to hold the events back.
response.setHeader("X-Accel-Buffering", "no");
response.setHeader("Cache-Control", "no-cache, no-store");
try {
this.streamService.open(this.readService.planFor(this.context.authenticatedUser()), sink, sse);
} catch (final RuntimeException e) {
sink.close();
throw e;
}
}
}
@@ -0,0 +1,14 @@
package org.apache.fineract.portfolio.approvals.data;
/**
* One Approval Center tab and the question that counts what waits on it.
*
* @param key
* the tab's route segment, which is also the key its badge reads on the screen
* @param permission
* the permission the tab is shown under (READ_* is also met by ALL_FUNCTIONS_READ); null means every caller sees
* the tab
* @param sql
* a scalar sub-select answering "how many", written with the named parameters of {@link ApprovalCountsPlan}
*/
public record ApprovalCounter(String key, String permission, String sql) {}
@@ -0,0 +1,27 @@
package org.apache.fineract.portfolio.approvals.data;
import com.google.gson.Gson;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* What is waiting for approval: the total, the number for each tab that has anything, and a version that is equal
* exactly when the numbers are, which is how a change is told from a repeat.
*/
public record ApprovalCountsData(long total, Map<String, Long> counts, String version) {
private static final Gson GSON = new Gson();
public static ApprovalCountsData of(final Map<String, Long> counts) {
final long total = counts.values().stream().mapToLong(Long::longValue).sum();
return new ApprovalCountsData(total, counts, Integer.toHexString(counts.hashCode()));
}
public String toJson() {
final Map<String, Object> json = new LinkedHashMap<>();
json.put("total", this.total);
json.put("counts", this.counts);
json.put("version", this.version);
return GSON.toJson(json);
}
}
@@ -0,0 +1,22 @@
package org.apache.fineract.portfolio.approvals.data;
import java.util.List;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
/**
* What one caller is counted for: the tabs they may see and the values (office scope, user, checker rights) those
* tabs' queries are asked with. It is worked out on the request thread, because it reads the user's roles, and can then
* be used from any thread.
*
* @param tenant
* the tenant the counts are read from
* @param visible
* the tabs this caller may see, in the order the Approval Center shows them
* @param params
* the named parameters the tabs' queries use
* @param group
* equal for callers who are counted identically, so they can share one query
*/
public record ApprovalCountsPlan(FineractPlatformTenant tenant, List<ApprovalCounter> visible, MapSqlParameterSource params,
String group) {}
@@ -0,0 +1,153 @@
package org.apache.fineract.portfolio.approvals.service;
import java.util.List;
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
/**
* Every Approval Center tab that carries a badge, and the question that counts what waits on it. Each question is the
* one the tab asks of its own API (same table, same status, same office scope, same entries left out). To badge a new
* tab, add one line here and one badge on the screen.
*
* The queries use these named parameters, supplied per caller: {@code :h} (office hierarchy pattern), {@code :ho} (head
* office), {@code :uid} (user) and {@code :superChecker} (may check every maker-checker command).
*/
public final class ApprovalCounterRegistry {
private ApprovalCounterRegistry() {}
/** Entities whose maker-checker requests are shown under Product Approval, not in the Checker Inbox. */
private static final String PRODUCT_ENTITIES = "'LOANPRODUCT','SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT','CHARGE'";
/** Product edits that are staged in m_saving_product_temp, so Product Approval already lists them from there. */
private static final String STAGED_PRODUCT_ENTITIES = "'SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT'";
/** Accounts whose UPDATE is staged for Pending Account Edits, so the Checker Inbox leaves it out. */
private static final String STAGED_ACCOUNT_ENTITIES = "'SAVINGSACCOUNT','CURRENTACCOUNT'";
private static final String DDS_ENTITIES = "'PARTY','AGENT','PARTYTRANSACTION','DDSACCOUNTTYPE','COMMISSIONSLAB',"
+ "'DDSBATCH','DDSCOLLECTIONENTRY'";
/** DDS commands held for a checker, as the DDS approval queue reads them. Aliased {@code c}. */
private static final String DDS_PENDING = "FROM m_portfolio_command_source c JOIN m_permission p "
+ "ON p.code = c.action_name || '_' || c.entity_name AND p.can_maker_checker = true WHERE c.status = 2 ";
/**
* DDS product and DDS account requests, listed under Product Approval and Pending Account with every
* other product and account rather than under DDS Approvals or the Checker Inbox. Written for any
* alias, since the Checker Inbox counts with {@code aud} and the DDS counts with {@code c}.
*/
private static String ddsProductRequest(final String alias) {
return "(" + alias + ".entity_name = 'DDSACCOUNTTYPE' AND " + alias + ".action_name IN ('CREATE','UPDATE'))";
}
private static String ddsAccountRequest(final String alias) {
return "(" + alias + ".entity_name = 'PARTY' AND " + alias + ".action_name IN ('CREATE','UPDATE'))";
}
/**
* A DDS agent request of any kind - opening, editing or closing one. An agent has no voucher and no
* amount, so these wait under their own Agent Approval tab rather than among the money entries of
* DDS Approvals or the Checker Inbox.
*/
private static String ddsAgentRequest(final String alias) {
return "(" + alias + ".entity_name = 'AGENT')";
}
/** Maker-checker commands awaiting a checker, limited to the offices and checker rights the caller has. */
private static final String MC_PENDING = "FROM m_portfolio_command_source aud WHERE aud.status = 2 "
+ "AND (:ho OR EXISTS (SELECT 1 FROM m_office o2 WHERE o2.id = aud.office_id AND o2.hierarchy LIKE :h)) "
+ "AND (:superChecker OR EXISTS (SELECT 1 FROM m_permission p JOIN m_role_permission rp ON rp.permission_id = p.id "
+ "JOIN m_appuser_role ur ON ur.role_id = rp.role_id AND ur.appuser_id = :uid "
+ "WHERE p.code LIKE '%\\_CHECKER' AND REPLACE(p.action_name, '_CHECKER', '') = aud.action_name "
+ "AND p.entity_name = aud.entity_name)) ";
/** Where the person who raised a loan or account sits, for the office scope the tab applies. */
private static final String CLIENT_OFFICE = "EXISTS (SELECT 1 FROM m_office o WHERE o.id = COALESCE(c.office_id, g.office_id) "
+ "AND o.hierarchy LIKE :h)";
/** The one tab whose list is open to managers and head cashiers only. */
public static final String DISBURSEMENT_REQUESTS = "disbursement-requests";
/** The tabs, in the order the Approval Center shows them. A null permission means every caller sees the tab. */
public static final List<ApprovalCounter> ALL = List.of(
// ── Fineract's own maker-checker and pending lists ────────────────────────────
new ApprovalCounter("checker-inbox", "ALL_FUNCTIONS_READ",
"SELECT count(*) " + MC_PENDING + "AND upper(aud.entity_name) NOT IN (" + PRODUCT_ENTITIES + ") "
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_ACCOUNT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE') "
+ "AND NOT " + ddsProductRequest("aud") + " AND NOT " + ddsAccountRequest("aud")
+ " AND NOT " + ddsAgentRequest("aud")),
new ApprovalCounter("client-approval", "READ_CLIENT",
"SELECT count(*) FROM m_client c WHERE c.status_enum = 100 "
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = c.office_id AND o.hierarchy LIKE :h)"),
new ApprovalCounter("loan-approval", "READ_LOAN",
"SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id "
+ "WHERE l.loan_status_id = 100 AND " + CLIENT_OFFICE),
new ApprovalCounter("loan-disbursal", "READ_LOAN",
"SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id "
+ "WHERE l.loan_status_id = 200 AND " + CLIENT_OFFICE),
new ApprovalCounter("reschedule-loan", "RESCHEDULE_LOAN",
"SELECT count(*) FROM m_loan_reschedule_request WHERE status_enum = 100"),
new ApprovalCounter("pending-account", "ALL_FUNCTIONS_READ",
"SELECT (SELECT count(*) FROM m_savings_account s LEFT JOIN m_client c ON c.id = s.client_id "
+ "LEFT JOIN m_group g ON g.id = s.group_id WHERE s.status_enum = 100 "
+ "AND COALESCE(s.terminate_true, false) = false AND " + CLIENT_OFFICE
+ ") + (SELECT count(*) FROM m_current_account ca WHERE ca.status_enum = 100 "
+ "AND COALESCE(ca.terminate_true, false) = false "
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = ca.office_id AND o.hierarchy LIKE :h)) "
+ "+ (SELECT count(*) " + DDS_PENDING + "AND " + ddsAccountRequest("c") + ")"),
new ApprovalCounter("pending-share-accounts", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_share_account WHERE status_enum = 100"),
new ApprovalCounter("product-approval", "ALL_FUNCTIONS_READ",
"SELECT (SELECT count(*) FROM m_saving_product_temp WHERE status = 'PENDING' "
+ "AND product_type IN ('SAVINGS','CURRENT','SHARE','FD','RD')) + (SELECT count(*) " + MC_PENDING
+ "AND upper(aud.entity_name) IN (" + PRODUCT_ENTITIES + ") "
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_PRODUCT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE')) "
+ "+ (SELECT count(*) " + DDS_PENDING + "AND " + ddsProductRequest("c") + ")"),
new ApprovalCounter("pending-savings-account-edits", "ALL_FUNCTIONS_READ",
"SELECT (SELECT count(*) FROM m_savings_account_pending_edit WHERE status = 'PENDING') "
+ "+ (SELECT count(*) FROM m_current_account_pending_edit WHERE status = 'PENDING') "
+ "+ (SELECT count(*) FROM m_share_account_pending_edit WHERE status = 'PENDING')"),
new ApprovalCounter("pending-client-edits", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_client_pending_edit pe JOIN m_client c ON c.id = pe.client_id WHERE pe.status = 'PENDING'"),
new ApprovalCounter("pending-user-edits", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_user_pending_request WHERE status = 'PENDING'"),
// ── Requests raised by clerks, cashiers and managers ──────────────────────────
new ApprovalCounter("withdrawal-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_withdrawal_request WHERE status = 'PENDING'"),
new ApprovalCounter("cash-transfers", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_cash_transfer_request WHERE status = 'PENDING'"),
new ApprovalCounter("voucher-corrections", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_voucher_correction WHERE status IN ('UNPASS_PENDING','CORRECTION_PENDING')"),
new ApprovalCounter("share-withdrawal-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_share_withdrawal_request WHERE status = 'PENDING'"),
new ApprovalCounter("dividend-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_share_product_dividend_pay_out p JOIN m_share_product sp ON sp.id = p.product_id "
+ "WHERE p.status = 100"),
new ApprovalCounter("share-addition-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_share_account_transactions t JOIN m_share_account sa ON sa.id = t.account_id "
+ "JOIN m_client c ON c.id = sa.client_id JOIN m_share_product sp ON sp.id = sa.product_id "
+ "WHERE t.is_active = true AND t.status_enum = 100 AND t.type_enum = 500"),
new ApprovalCounter("deposit-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_cashier_deposit_request WHERE status = 'PENDING'"),
new ApprovalCounter(DISBURSEMENT_REQUESTS, "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_disbursement_request WHERE status = 'PENDING'"),
new ApprovalCounter("dds-approvals", null,
"SELECT count(*) " + DDS_PENDING + "AND c.entity_name IN (" + DDS_ENTITIES + ") "
+ "AND NOT " + ddsProductRequest("c") + " AND NOT " + ddsAccountRequest("c")
+ " AND NOT " + ddsAgentRequest("c")),
new ApprovalCounter("agent-approvals", null, "SELECT count(*) " + DDS_PENDING + "AND " + ddsAgentRequest("c")),
new ApprovalCounter("chequebook-requests", "READ_CHEQUEBOOKPENDINGREQUEST",
"SELECT count(*) FROM m_chequebook_pending_request WHERE status = 'PENDING'"),
new ApprovalCounter("closure-requests", "ALL_FUNCTIONS_READ",
"SELECT count(*) FROM m_clerk_closure_request WHERE status = 'PENDING'"),
new ApprovalCounter("dd-issue-approvals", null,
"SELECT count(*) FROM m_sponsor_dd_issue_request WHERE status = 'PENDING'"),
new ApprovalCounter("dd-reprint-approvals", null,
"SELECT count(*) FROM m_sponsor_dd_reprint_request WHERE status = 'PENDING'"),
new ApprovalCounter("dd-revoke-approvals", null,
"SELECT count(*) FROM m_sponsor_dd_revoke_request WHERE status = 'PENDING'"),
new ApprovalCounter("risk-change-approvals", null,
"SELECT count(*) FROM m_client_risk_change_request WHERE status = 'PENDING'"));
}
@@ -0,0 +1,24 @@
package org.apache.fineract.portfolio.approvals.service;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
import org.apache.fineract.useradministration.domain.AppUser;
/** Reads how many requests wait on each Approval Center tab, in one statement. */
public interface ApprovalCountsReadPlatformService {
/**
* Works out what the caller is counted for. Call it on the request thread: it reads the user's roles and the
* tenant of the current request.
*/
ApprovalCountsPlan planFor(AppUser user);
/** Counts now, for the tabs in the plan. The plan's tenant must be the current tenant of the calling thread. */
ApprovalCountsData retrieve(ApprovalCountsPlan plan);
/**
* Counts for the caller, from the answer held for a few seconds when there is one, so many screens asking together
* cost the database one query. {@code fresh} skips the held answer.
*/
ApprovalCountsData retrieveHeld(AppUser user, boolean fresh);
}
@@ -0,0 +1,114 @@
package org.apache.fineract.portfolio.approvals.service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
/**
* All the visible tabs' questions go to the database as one statement (one scalar sub-select per tab), so the cost is
* one connection and one plan instead of a call per tab. A tab the caller may not see is simply not asked.
*/
@Service
@RequiredArgsConstructor
public class ApprovalCountsReadPlatformServiceImpl implements ApprovalCountsReadPlatformService {
/** Screens asking together read one answer instead of each running the statement. */
private static final long HOLD_MILLIS = 3_000L;
private record Held(long at, ApprovalCountsData data) {}
private final JdbcTemplate jdbcTemplate;
private final Map<String, Held> held = new ConcurrentHashMap<>();
@Override
public ApprovalCountsPlan planFor(final AppUser user) {
final List<ApprovalCounter> visible = new ArrayList<>();
for (final ApprovalCounter counter : ApprovalCounterRegistry.ALL) {
if (isVisible(user, counter)) {
visible.add(counter);
}
}
final String hierarchy = user.getOffice().getHierarchy();
final boolean superChecker = !user.hasNotPermissionForAnyOf("ALL_FUNCTIONS", "CHECKER_SUPER_USER");
final MapSqlParameterSource params = new MapSqlParameterSource().addValue("h", hierarchy + "%")
.addValue("ho", ".".equals(hierarchy)).addValue("uid", user.getId()).addValue("superChecker", superChecker);
final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant();
// Callers counted the same way share one query: only a limited checker's numbers depend on who they are.
final String group = tenant.getTenantIdentifier() + '|' + hierarchy + '|' + superChecker + '|'
+ (superChecker ? "" : user.getId()) + '|' + visible.stream().map(ApprovalCounter::key).collect(Collectors.joining(","));
return new ApprovalCountsPlan(tenant, visible, params, group);
}
@Override
public ApprovalCountsData retrieve(final ApprovalCountsPlan plan) {
final Map<String, Long> counts = new LinkedHashMap<>();
final List<ApprovalCounter> visible = plan.visible();
if (!visible.isEmpty()) {
final StringBuilder sql = new StringBuilder("SELECT ");
for (int i = 0; i < visible.size(); i++) {
sql.append(i == 0 ? "" : ", ").append('(').append(visible.get(i).sql()).append(") AS c").append(i);
}
final Map<String, Object> row = new NamedParameterJdbcTemplate(this.jdbcTemplate).queryForMap(sql.toString(),
plan.params());
for (int i = 0; i < visible.size(); i++) {
final long n = row.get("c" + i) instanceof Number number ? number.longValue() : 0L;
if (n > 0) {
counts.put(visible.get(i).key(), n);
}
}
}
return ApprovalCountsData.of(counts);
}
@Override
public ApprovalCountsData retrieveHeld(final AppUser user, final boolean fresh) {
final ApprovalCountsPlan plan = planFor(user);
final String holdKey = plan.tenant().getTenantIdentifier() + ':' + user.getId();
final long now = System.currentTimeMillis();
final Held recent = this.held.get(holdKey);
if (!fresh && recent != null && now - recent.at() < HOLD_MILLIS) {
return recent.data();
}
final ApprovalCountsData data = retrieve(plan);
this.held.put(holdKey, new Held(now, data));
if (this.held.size() > 500) {
this.held.entrySet().removeIf(e -> now - e.getValue().at() >= HOLD_MILLIS);
}
return data;
}
/** The tab's own visibility rule: its permission (READ_* is also met by ALL_FUNCTIONS_READ) and, for one tab, its role. */
private boolean isVisible(final AppUser user, final ApprovalCounter counter) {
if (counter.permission() != null) {
final boolean allowed = counter.permission().startsWith("READ_")
? user.hasAnyPermission(counter.permission(), "ALL_FUNCTIONS_READ")
: user.hasAnyPermission(counter.permission());
if (!allowed) {
return false;
}
}
return !ApprovalCounterRegistry.DISBURSEMENT_REQUESTS.equals(counter.key()) || isChecker(user);
}
private boolean isChecker(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"));
}
}
@@ -0,0 +1,145 @@
package org.apache.fineract.portfolio.approvals.service;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.ws.rs.sse.OutboundSseEvent;
import jakarta.ws.rs.sse.Sse;
import jakarta.ws.rs.sse.SseEventSink;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
import org.springframework.stereotype.Service;
/**
* Keeps the Approvals badge live. A screen listens on a Server-Sent Events stream and is written to the moment its
* numbers change, so it neither polls nor reloads.
*
* One shared check, every {@value #CHECK_MILLIS} ms, serves every open screen, and screens counted the same way share
* its query. A comment line every {@value #PING_MILLIS} ms keeps proxies from closing an idle connection and tells the
* server when a screen has gone. A stream ends after {@value #LIFETIME_MILLIS} ms so the screen reconnects with a clean
* connection, which bounds what a browser accumulates on one.
*/
@Service
@RequiredArgsConstructor
public class ApprovalCountsStreamService {
/** How often the open screens are looked at: the longest a change waits before it is pushed. */
private static final long CHECK_MILLIS = 1_000L;
private static final long PING_MILLIS = 15_000L;
private static final long LIFETIME_MILLIS = 300_000L;
/** A screen listening, and the version of the numbers it was last sent. */
private static final class Listener {
private final ApprovalCountsPlan plan;
private final SseEventSink sink;
private final Sse sse;
private final long expiresAt;
private volatile String version;
private volatile long lastSent;
private Listener(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse, final long expiresAt) {
this.plan = plan;
this.sink = sink;
this.sse = sse;
this.expiresAt = expiresAt;
}
}
private final ApprovalCountsReadPlatformService readService;
private final Set<Listener> listeners = ConcurrentHashMap.newKeySet();
private final ScheduledExecutorService ticker = Executors.newSingleThreadScheduledExecutor(runnable -> {
final Thread thread = new Thread(runnable, "approval-counts-stream");
thread.setDaemon(true);
return thread;
});
@PostConstruct
void startTicker() {
this.ticker.scheduleWithFixedDelay(this::tick, CHECK_MILLIS, CHECK_MILLIS, TimeUnit.MILLISECONDS);
}
@PreDestroy
void stopTicker() {
this.ticker.shutdownNow();
this.listeners.forEach(this::close);
}
/** Sends the numbers as they stand, then keeps the screen informed. Call it on the request thread. */
public void open(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse) {
final Listener listener = new Listener(plan, sink, sse, System.currentTimeMillis() + LIFETIME_MILLIS);
try {
publish(listener, this.readService.retrieve(plan));
} catch (final RuntimeException e) {
sink.close();
throw e;
}
this.listeners.add(listener);
}
/** Looks at the numbers for every open screen and writes to the ones whose numbers moved. */
private void tick() {
if (this.listeners.isEmpty()) {
return;
}
final long now = System.currentTimeMillis();
final Map<String, ApprovalCountsData> computed = new HashMap<>();
for (final Listener listener : this.listeners) {
try {
if (listener.sink.isClosed() || now >= listener.expiresAt) {
close(listener);
continue;
}
ApprovalCountsData data = computed.get(listener.plan.group());
if (data == null) {
ThreadLocalContextUtil.setTenant(listener.plan.tenant());
data = this.readService.retrieve(listener.plan);
computed.put(listener.plan.group(), data);
}
if (!data.version().equals(listener.version)) {
publish(listener, data);
} else if (now - listener.lastSent >= PING_MILLIS) {
listener.lastSent = now;
send(listener, listener.sse.newEventBuilder().comment("ping").build());
}
} catch (final RuntimeException e) {
close(listener);
} finally {
ThreadLocalContextUtil.clearTenant();
}
}
}
private void publish(final Listener listener, final ApprovalCountsData data) {
listener.version = data.version();
listener.lastSent = System.currentTimeMillis();
send(listener, listener.sse.newEvent("counts", data.toJson()));
}
private void send(final Listener listener, final OutboundSseEvent event) {
listener.sink.send(event).whenComplete((sent, failure) -> {
if (failure != null) {
close(listener);
}
});
}
private void close(final Listener listener) {
this.listeners.remove(listener);
try {
listener.sink.close();
} catch (final RuntimeException ignored) {
// The screen is already gone.
}
}
}
@@ -56,6 +56,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage; import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -98,6 +99,8 @@ public class CashierDepositRequestApiResource {
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService; private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final org.apache.fineract.accounting.interbranch.service.InterBranchCashService interBranchCashService;
private final Gson gson = new GsonBuilder().serializeNulls().create(); private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ── // ── POST /v1/cashier/deposit-requests ── Cashier submits new deposit request ──
@@ -155,6 +158,11 @@ public class CashierDepositRequestApiResource {
throw apiError(400, "Amount must be greater than zero"); throw apiError(400, "Amount must be greater than zero");
} }
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT); kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT);
// An FD is funded once, with exactly its principal. Told here at the counter rather than when the
// manager approves; the FD deposit command checks the same again at posting.
if (!isGlType && "FIXED_DEPOSIT".equals(resolveDepositAccountCategory(savingsAccountId, accountNo))) {
fixedDepositFundingValidator.validateFirstFunding(savingsAccountId, amount);
}
validateDenominationTotal(denominationData, amount); validateDenominationTotal(denominationData, amount);
validateDdLimit(ddFaceValue); validateDdLimit(ddFaceValue);
@@ -209,8 +217,9 @@ public class CashierDepositRequestApiResource {
@QueryParam("fromDate") final String fromDate, @QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate) { @QueryParam("toDate") final String toDate) {
final StringBuilder where = new StringBuilder("WHERE status = ?"); // A branch reviews only the requests its own counters raised (and those of branches below it).
final List<Object> baseParams = new ArrayList<>(List.of(status)); final StringBuilder where = new StringBuilder("WHERE status = ? AND " + REQUEST_BRANCH_IN_HIERARCHY);
final List<Object> baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%"));
if (fromDate != null && !fromDate.isBlank()) { if (fromDate != null && !fromDate.isBlank()) {
where.append(" AND DATE(submitted_at) >= ?"); where.append(" AND DATE(submitted_at) >= ?");
baseParams.add(Date.valueOf(fromDate)); baseParams.add(Date.valueOf(fromDate));
@@ -239,9 +248,13 @@ public class CashierDepositRequestApiResource {
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray()); pageParams.toArray());
final List<Map<String, Object>> pageItems = new ArrayList<>();
for (final Map<String, Object> row : rows) {
pageItems.add(withBranches(new java.util.LinkedHashMap<>(row)));
}
final JsonObject result = new JsonObject(); final JsonObject result = new JsonObject();
result.addProperty("totalFilteredRecords", total); result.addProperty("totalFilteredRecords", total);
result.add("pageItems", gson.toJsonTree(rows)); result.add("pageItems", gson.toJsonTree(pageItems));
return gson.toJson(result); return gson.toJson(result);
} }
@@ -306,7 +319,82 @@ public class CashierDepositRequestApiResource {
@Path("/{id}") @Path("/{id}")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public String getById(@PathParam("id") final Long id) { public String getById(@PathParam("id") final Long id) {
return gson.toJson(loadRecord(id)); final Map<?, ?> record = loadRecord(id);
assertRequestInCallersBranch(record);
final Map<String, Object> detail = new java.util.LinkedHashMap<>();
record.forEach((key, value) -> detail.put(String.valueOf(key), value));
return gson.toJson(withBranches(detail));
}
/**
* Adds the two branches a deposit request involves: where the cash was taken (the cashier's counter) and where the
* account is held. When they differ the request is an inter-branch deposit, which the reviewer must be able to see.
*/
private Map<String, Object> withBranches(final Map<String, Object> row) {
final Object requestId = row.get("id");
final List<Map<String, Object>> counter = jdbcTemplate.queryForList(
"SELECT o.id, o.name FROM m_cashier_deposit_request r LEFT JOIN m_appuser au ON au.id = r.cashier_id "
+ "JOIN m_office o ON o.id = COALESCE(r.counter_office_id, au.office_id) WHERE r.id = ?", requestId);
final List<Map<String, Object>> account = jdbcTemplate.queryForList("""
SELECT o.id, o.name FROM m_cashier_deposit_request r
LEFT JOIN m_savings_account sa ON sa.id = r.savings_account_id AND sa.account_no = r.account_no
LEFT JOIN m_client sc ON sc.id = sa.client_id
LEFT JOIN m_current_account ca ON ca.id = r.savings_account_id AND ca.account_no = r.account_no
LEFT JOIN m_client cc ON cc.id = ca.client_id
JOIN m_office o ON o.id = COALESCE(CASE WHEN r.account_type = 'GL' THEN r.office_id END,
sa.office_id, sc.office_id, ca.office_id, cc.office_id)
WHERE r.id = ?
""", requestId);
final Long counterId = counter.isEmpty() ? null : ((Number) counter.get(0).get("id")).longValue();
final Long accountId = account.isEmpty() ? null : ((Number) account.get(0).get("id")).longValue();
row.put("counter_office_name", counter.isEmpty() ? null : counter.get(0).get("name"));
row.put("account_office_name", account.isEmpty() ? null : account.get(0).get("name"));
row.put("counter_branch_id", counterId);
row.put("account_branch_id", accountId);
row.put("inter_branch", counterId != null && accountId != null && !counterId.equals(accountId));
return row;
}
/**
* A cash deposit posts in the account's branch, but the cash is in the drawer of the cashier who took it. When those
* are different branches, moves the cash line of the posting to the counter's branch through Liability Transfer.
* Only a cash payment has a cash line, and only a savings-table deposit (savings, FD, RD) is covered here.
*/
private void moveCashToCounterBranch(final Map<?, ?> record, final String txnId, final BigDecimal amount, final String txnDateStr) {
final Object paymentType = record.get("payment_type_name");
final Long savingsTxnId = parseTxnIdToLong(txnId);
if (savingsTxnId == null || paymentType == null || !"cash".equalsIgnoreCase(paymentType.toString().trim())) {
return;
}
final Map<String, Object> seed = new java.util.LinkedHashMap<>();
seed.put("id", record.get("id"));
final Map<String, Object> branches = withBranches(seed);
final Long counterId = (Long) branches.get("counter_branch_id");
final Long accountId = (Long) branches.get("account_branch_id");
if (counterId == null || accountId == null || counterId.equals(accountId)) {
return;
}
final java.time.LocalDate date = txnDateStr != null && !txnDateStr.isBlank() ? java.time.LocalDate.parse(txnDateStr)
: DateUtils.getBusinessLocalDate();
interBranchCashService.moveCashLegToBranch(savingsTxnId, accountId, counterId, String.valueOf(record.get("account_no")), amount,
date, true);
}
/**
* Rows whose branch (the counter that took the cash; the cashier's own office for requests raised before that was
* recorded) is at or below the caller's office. Takes the caller's hierarchy plus "%" as its one parameter.
*/
private static final String REQUEST_BRANCH_IN_HIERARCHY = "COALESCE(counter_office_id, "
+ "(SELECT au.office_id FROM m_appuser au WHERE au.id = cashier_id)) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)";
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
private void assertRequestInCallersBranch(final Map<?, ?> record) {
final Integer visible = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cashier_deposit_request WHERE id = ? AND " + REQUEST_BRANCH_IN_HIERARCHY, Integer.class,
((Number) record.get("id")).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
if (visible == null || visible == 0) {
throw new NotFoundException("Deposit request not found: " + record.get("id"));
}
} }
// ── PUT /v1/cashier/deposit-requests/{id} ── Cashier edits rejected request ── // ── PUT /v1/cashier/deposit-requests/{id} ── Cashier edits rejected request ──
@@ -393,6 +481,7 @@ public class CashierDepositRequestApiResource {
final AppUser currentUser = context.authenticatedUser(); final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser); validateCheckerRole(currentUser);
final Map<?, ?> record = loadRecord(id); final Map<?, ?> record = loadRecord(id);
assertRequestInCallersBranch(record);
if (!"PENDING".equals(record.get("status"))) { if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status")); throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
@@ -450,6 +539,7 @@ public class CashierDepositRequestApiResource {
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description); default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
}; };
if (!isCurrentAccount && txnId != null) { if (!isCurrentAccount && txnId != null) {
moveCashToCounterBranch(record, txnId, amount, txnDateStr);
// approve() is one @Transactional block, so an uncaught exception here would roll back // approve() is one @Transactional block, so an uncaught exception here would roll back
// the deposit that was just posted along with it -- every Cheque/DD approval that ever // the deposit that was just posted along with it -- every Cheque/DD approval that ever
// hit an edge case in the new hold-placement logic would fail entirely, where before // hit an edge case in the new hold-placement logic would fail entirely, where before
@@ -530,6 +620,7 @@ public class CashierDepositRequestApiResource {
final AppUser currentUser = context.authenticatedUser(); final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser); validateCheckerRole(currentUser);
final Map<?, ?> record = loadRecord(id); final Map<?, ?> record = loadRecord(id);
assertRequestInCallersBranch(record);
if (!"PENDING".equals(record.get("status"))) { if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status")); throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
@@ -20,6 +20,7 @@ import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger; import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter; import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -36,6 +37,7 @@ public class CashReportApiResource {
private final DenominationBreakdownWriter denominationBreakdownWriter; private final DenominationBreakdownWriter denominationBreakdownWriter;
private final CashierRoleHelper cashierRoleHelper; private final CashierRoleHelper cashierRoleHelper;
private final VaultPositionService vaultPositionService; private final VaultPositionService vaultPositionService;
private final VoucherService voucherService;
private final Gson gson = new Gson(); private final Gson gson = new Gson();
/** /**
@@ -329,7 +331,18 @@ public class CashReportApiResource {
BigDecimal priorCreditTotal = BigDecimal.ZERO; BigDecimal priorCreditTotal = BigDecimal.ZERO;
BigDecimal priorDebitTotal = BigDecimal.ZERO; BigDecimal priorDebitTotal = BigDecimal.ZERO;
// Where each voucher stands after a correction: a revoked voucher is listed, marked, but its money now sits on
// the voucher that replaced it, so it must not be counted in the day's totals a second time.
final Map<String, Map<String, Object>> marks = voucherService.voucherMarks(
rows.stream().map(r -> (String) r.get("voucherNo")).toList());
for (final Map<String, Object> row : rows) { for (final Map<String, Object> row : rows) {
final Map<String, Object> mark = marks.get((String) row.get("voucherNo"));
row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus"));
row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo"));
row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo"));
row.put("correctionNote", mark == null ? null : mark.get("correctionNote"));
final boolean revoked = "REVOKED".equals(row.get("voucherStatus"));
if (sideFilter != null && !sideFilter.equals(row.get("side"))) { if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
continue; continue;
} }
@@ -350,7 +363,7 @@ public class CashReportApiResource {
// was already earned, and the day it belongs to has been counted and signed off. Adding // was already earned, and the day it belongs to has been counted and signed off. Adding
// it to this day's totals would make them disagree with the ledger for either date. // it to this day's totals would make them disagree with the ledger for either date.
final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section")); final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section"));
if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus) if (!priorDay && !revoked && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) { || "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) {
final BigDecimal amount = (BigDecimal) row.get("amount"); final BigDecimal amount = (BigDecimal) row.get("amount");
if (amount != null) { if (amount != null) {
@@ -57,9 +57,23 @@ public class CashTransferApiResource {
private final VaultPositionService vaultPositionService; private final VaultPositionService vaultPositionService;
private final Gson gson = new Gson(); private final Gson gson = new Gson();
/**
* Cash no longer leaves the vault on one person's say-so: the allocation is raised as a request (see
* {@link #submitRequest}) and only a different person's approval posts it. The posting itself is
* {@link #postTransfer}.
*/
@POST @POST
public String create(String body) { public String create(String body) {
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER"); throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.required",
"A cash transfer is raised as a request and posted when a manager approves it.");
}
/**
* Validates a transfer and, unless {@code dryRun}, posts it: the header and its denominations, then the GL entry.
* A dry run stops after every check has passed, which is what a request is held to when it is raised and again
* when it is approved, since the vault can change in between.
*/
private String postTransfer(final String body, final Long performedBy, final String performedByUsername, final boolean dryRun) {
JsonObject json = JsonParser.parseString(body).getAsJsonObject(); JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull() final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
@@ -102,6 +116,9 @@ public class CashTransferApiResource {
} }
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate); validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
if (dryRun) {
return "{}";
}
CashTransfer header = new CashTransfer(); CashTransfer header = new CashTransfer();
header.setTransferDate(transferDate); header.setTransferDate(transferDate);
@@ -112,15 +129,9 @@ public class CashTransferApiResource {
header.setTransferAmount(total); header.setTransferAmount(total);
header.setNote(note); header.setNote(note);
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
try { // Recorded against the person who raised the request, not the one who approved it.
AppUser user = this.context.authenticatedUser(); header.setPerformedBy(performedBy);
if (user != null) { header.setPerformedByUsername(performedByUsername);
header.setPerformedBy(user.getId());
header.setPerformedByUsername(user.getUsername());
}
} catch (Exception e) {
// resolving the current user must never block recording the transfer
}
// Save header + details in one committed transaction, then GL dispatch separately. // Save header + details in one committed transaction, then GL dispatch separately.
// This prevents EclipseLink from flushing pending detail rows inside Fineract's // This prevents EclipseLink from flushing pending detail rows inside Fineract's
@@ -159,6 +170,171 @@ public class CashTransferApiResource {
return gson.toJson(result); return gson.toJson(result);
} }
// ── Maker-checker: the allocation is a request until a different person approves it ────────────
/** The maker raises the allocation. Nothing moves yet: every check runs, and the request waits for a checker. */
@POST
@Path("requests")
public String submitRequest(final String body) {
final AppUser user = this.context.authenticatedUser();
user.validateHasPermissionTo("CREATE_CASHTRANSFER");
postTransfer(body, user.getId(), user.getUsername(), true);
// The same request, from the same person, is not raised twice while the first is still waiting: a second tap or
// a second click would otherwise release the same cash twice once both were approved.
final Integer identical = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_cash_transfer_request WHERE status = ? AND maker_id = ? AND request_body = ?", Integer.class,
REQUEST_PENDING, user.getId(), body);
if (identical != null && identical > 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.duplicate",
"An identical cash transfer request from you is already waiting for approval.");
}
final JsonObject json = JsonParser.parseString(body).getAsJsonObject();
final JsonArray denominations = json.getAsJsonArray("denominations");
final Long id = jdbcTemplate.queryForObject("""
INSERT INTO m_cash_transfer_request
(transfer_date, from_teller_id, to_cashier_id, from_party, to_party, note, total_amount, request_body,
status, maker_id, maker_name, maker_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""", Long.class, textOrNull(json, "transferDate") == null ? null : java.sql.Date.valueOf(textOrNull(json, "transferDate")),
json.get("fromTellerId").getAsLong(), json.get("toCashierId").getAsLong(), textOrNull(json, "fromParty"),
textOrNull(json, "toParty"), textOrNull(json, "note"), sumTotal(denominations), body, REQUEST_PENDING, user.getId(),
user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()));
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("status", REQUEST_PENDING);
return gson.toJson(result);
}
/** Requests by status (comma separated); {@code mine} narrows them to the ones the caller raised. */
@GET
@Path("requests")
public String listRequests(@QueryParam("status") final String status,
@QueryParam("mine") @jakarta.ws.rs.DefaultValue("false") final boolean mine) {
final AppUser user = this.context.authenticatedUser();
final StringBuilder sql = new StringBuilder("SELECT * FROM m_cash_transfer_request WHERE 1=1");
final List<Object> params = new java.util.ArrayList<>();
if (status != null && !status.isBlank()) {
final String[] statuses = status.split(",");
sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")");
for (final String s : statuses) {
params.add(s.trim().toUpperCase());
}
}
if (mine) {
sql.append(" AND maker_id = ?");
params.add(user.getId());
}
sql.append(" ORDER BY id DESC LIMIT 200");
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray());
for (final Map<String, Object> row : rows) {
// The notes asked for, so the approver sees what they are releasing without opening anything.
final Object requestBody = row.remove("request_body");
row.put("denominations", requestBody == null ? new JsonArray()
: JsonParser.parseString(requestBody.toString()).getAsJsonObject().getAsJsonArray("denominations"));
}
return gson.toJson(rows);
}
/** The checker releases the cash: re-checks the vault, then posts the transfer exactly as a direct one used to. */
@POST
@Path("requests/{id}/approve")
public String approveRequest(@PathParam("id") final Long id) {
final AppUser user = this.context.authenticatedUser();
requireChecker(user);
final Map<String, Object> r = loadRequest(id);
requirePending(r, "approved");
final Long makerId = r.get("maker_id") instanceof Number n ? n.longValue() : null;
if (makerId != null && makerId.equals(user.getId())) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.same.user",
"Maker-checker: you cannot approve a cash transfer you raised yourself. Ask another manager.");
}
final String posted = postTransfer((String) r.get("request_body"), makerId, (String) r.get("maker_name"), false);
final JsonObject postedJson = JsonParser.parseString(posted).getAsJsonObject();
final Long transferId = postedJson.has("resourceId") ? postedJson.get("resourceId").getAsLong() : null;
final int updated = jdbcTemplate.update("""
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, cash_transfer_id = ?
WHERE id = ? AND status = ?
""", REQUEST_APPROVED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
transferId, id, REQUEST_PENDING);
if (updated == 0) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.actioned",
"This request was already actioned by another manager.");
}
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("status", REQUEST_APPROVED);
if (transferId != null) {
result.addProperty("cashTransferId", transferId);
}
if (postedJson.has("glWarning")) {
result.add("glWarning", postedJson.get("glWarning"));
}
return gson.toJson(result);
}
@POST
@Path("requests/{id}/reject")
public String rejectRequest(@PathParam("id") final Long id, final String body) {
final AppUser user = this.context.authenticatedUser();
requireChecker(user);
final Map<String, Object> r = loadRequest(id);
requirePending(r, "rejected");
final JsonObject json = body == null || body.isBlank() ? new JsonObject() : JsonParser.parseString(body).getAsJsonObject();
final String reason = textOrNull(json, "reason");
if (reason == null || reason.isBlank()) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.reason", "A reason is required to reject.");
}
jdbcTemplate.update("""
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, rejection_reason = ?
WHERE id = ? AND status = ?
""", REQUEST_REJECTED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
reason.trim(), id, REQUEST_PENDING);
final JsonObject result = new JsonObject();
result.addProperty("resourceId", id);
result.addProperty("status", REQUEST_REJECTED);
return gson.toJson(result);
}
private static final String REQUEST_PENDING = "PENDING";
private static final String REQUEST_APPROVED = "APPROVED";
private static final String REQUEST_REJECTED = "REJECTED";
private Map<String, Object> loadRequest(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_cash_transfer_request WHERE id = ?", id);
if (rows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.not.found", "Cash transfer request " + id + " not found.");
}
return rows.get(0);
}
private void requirePending(final Map<String, Object> request, final String action) {
if (!REQUEST_PENDING.equals(request.get("status"))) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.status",
"Only PENDING requests can be " + action + ". Current status: " + request.get("status"));
}
}
/** Checker authorization, the same rule the withdrawal and deposit request registers use. */
private void requireChecker(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
if (!isChecker) {
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.checker",
"Only a Bank Manager (or Head Cashier) can approve or reject a cash transfer.");
}
}
private static String textOrNull(final JsonObject json, final String key) {
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
}
@GET @GET
public String getAll( public String getAll(
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset, @QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
@@ -577,11 +577,11 @@ public class ShareAdvanceCollectionApiResource {
} }
// A separately-verified entry-fee receipt, when the member's fee was collected as its own // A separately-verified entry-fee receipt, when the member's fee was collected as its own
// receipt into whatever GL account the cashier actually deposited it to -- NOT into Sundry. // receipt into whatever GL account the cashier actually deposited it to. This id proves the
// This id proves the fee was paid; it never contributes to the Sundry leg below, and no GL // fee was paid and never contributes to the share-value leg below. If the cashier banked it
// entry moves its money, because it was never sitting in Sundry to begin with. Omit it (leave // into an income account it is left there; anywhere else (Sundry, most often) it is moved to
// null) when the fee was paid as part of the same Sundry receipt(s) as the share value -- // Share Entry Fee Income below. Omit it (leave null) when the fee was paid as part of the same
// that legacy, single-receipt case still needs the fee amount swept out of Sundry below. // Sundry receipt(s) as the share value -- that single-receipt case is swept out of Sundry below.
final Long chargeCollectionId = getLong(json, "chargeCollectionId"); final Long chargeCollectionId = getLong(json, "chargeCollectionId");
final List<Map<String, Object>> collections = new java.util.ArrayList<>(); final List<Map<String, Object>> collections = new java.util.ArrayList<>();
@@ -748,6 +748,20 @@ public class ShareAdvanceCollectionApiResource {
insertEntry(officeId, glIncome, CREDIT, feeStillToRecognise, txnId, entryDate, shareAccountId, userId, now, insertEntry(officeId, glIncome, CREDIT, feeStillToRecognise, txnId, entryDate, shareAccountId, userId, now,
currencyCode, "Share entry fee for " + accountNo); currencyCode, "Share entry fee for " + accountNo);
} }
} else {
// A separate fee receipt is only already income if the cashier banked it into an income
// account. One deposited into Sundry (or any other non-income account) sat there for
// good: nothing here moved it, so the year-end close never swept it into profit. Move
// the fee out of wherever it actually sits; any over-payment on that receipt stays put,
// as before.
final Long feeReceiptGl = receiptGlOf((String) chargeColl.get("gl_transaction_id"), glSuspense);
if (!isIncomeGl(feeReceiptGl)) {
final Long glIncome = requireGl(productId, FA_INCOME_FROM_FEES, "Share Entry Fee Income");
insertEntry(officeId, feeReceiptGl, DEBIT, chargeAmount, txnId, entryDate, shareAccountId, userId, now, currencyCode,
"Share entry fee settled | Receipt " + chargeColl.get("receipt_no") + " | " + accountNo);
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, " 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); + " amount_outstanding_derived = 0 WHERE account_id = ? AND is_active = true", shareAccountId);
@@ -239,7 +239,11 @@ public class ChequeBookIssuanceChargeService {
final Long ownAccountGlId = accountType == ChequeAccountType.SAVINGS ? getSavingsControlGlId(accountId) final Long ownAccountGlId = accountType == ChequeAccountType.SAVINGS ? getSavingsControlGlId(accountId)
: getCurrentControlGlId(accountId); : getCurrentControlGlId(accountId);
final Long incomeGlId = charge.getAccount() != null ? charge.getAccount().getId() : null; // A charge that names no income GL of its own falls back to the product's Income From Fees, the
// same order the savings charge bridge follows. Facility charges are seeded with no GL, so without
// this every cheque book was issued free and the fee never reached income or the year's profit.
final Long incomeGlId = charge.getAccount() != null ? charge.getAccount().getId()
: getProductFeeIncomeGlId(accountType, accountId);
if (ownAccountGlId == null || incomeGlId == null) { if (ownAccountGlId == null || incomeGlId == null) {
log.warn("Cannot post GL for cheque book {} issuance charge - missing control GL ({}) or income GL ({}); " log.warn("Cannot post GL for cheque book {} issuance charge - missing control GL ({}) or income GL ({}); "
+ "charge deduction skipped to avoid an unbalanced ledger.", chequeBookId, ownAccountGlId, incomeGlId); + "charge deduction skipped to avoid an unbalanced ledger.", chequeBookId, ownAccountGlId, incomeGlId);
@@ -345,6 +349,19 @@ public class ChequeBookIssuanceChargeService {
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue(); return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
} }
/** The account's product's Income From Fees mapping (financial_account_type 4), or null if the product maps none. */
private Long getProductFeeIncomeGlId(final ChequeAccountType accountType, final Long accountId) {
final String sql = accountType == ChequeAccountType.SAVINGS
? "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 = 2 AND apm.financial_account_type = 4 AND apm.charge_id IS NULL "
+ "AND apm.payment_type IS NULL LIMIT 1"
: "SELECT apm.gl_account_id FROM m_current_account ca JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
+ "WHERE ca.id = ? AND apm.product_type = 6 AND apm.financial_account_type = 4 AND apm.charge_id IS NULL "
+ "AND apm.payment_type IS NULL LIMIT 1";
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, accountId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
}
/** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */ /** DR the account's own control GL (liability down), CR the charge's income GL (fee income). */
private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId, private void postChargeJournalEntry(final Long officeId, final String currencyCode, final LocalDate txnDate, final Long debitGlId,
final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId, final Long creditGlId, final BigDecimal amount, final Long chequeBookId, final Long accountId,
@@ -69,6 +69,8 @@ import org.apache.fineract.infrastructure.security.service.PlatformSecurityConte
import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
@@ -90,6 +92,8 @@ public class ClerkDisbursementRequestApiResource {
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final FineractRequestContextHolder fineractRequestContextHolder; private final FineractRequestContextHolder fineractRequestContextHolder;
private final VoucherService voucherService; private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final Gson gson = new GsonBuilder().serializeNulls().create(); private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/clerk/disbursement-requests ── Clerk submits new request ──────── // ── POST /v1/clerk/disbursement-requests ── Clerk submits new request ────────
@@ -202,6 +206,9 @@ public class ClerkDisbursementRequestApiResource {
} }
} }
// Fixed deposit legs: credit-only, funded once with exactly the principal, from the customer's own account.
validateFixedDepositLegs(creditEntries, requestType);
// Validate balance when both DR and CR entries present (collection mode) // Validate balance when both DR and CR entries present (collection mode)
if (totalDr.compareTo(BigDecimal.ZERO) > 0 && totalCr.compareTo(BigDecimal.ZERO) > 0 if (totalDr.compareTo(BigDecimal.ZERO) > 0 && totalCr.compareTo(BigDecimal.ZERO) > 0
&& totalDr.subtract(totalCr).abs().compareTo(new BigDecimal("0.01")) > 0) { && totalDr.subtract(totalCr).abs().compareTo(new BigDecimal("0.01")) > 0) {
@@ -298,6 +305,10 @@ public class ClerkDisbursementRequestApiResource {
final StringBuilder where = new StringBuilder("WHERE status = ?"); final StringBuilder where = new StringBuilder("WHERE status = ?");
final List<Object> baseParams = new ArrayList<>(List.of(status)); final List<Object> baseParams = new ArrayList<>(List.of(status));
// A request belongs to the branch it was raised at. A checker sees their own branch's requests and those of the
// branches below it; Head Office, the root, sees every branch's.
where.append(" AND office_id IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)");
baseParams.add(currentUser.getOffice().getHierarchy() + "%");
if (fromDate != null && !fromDate.isBlank()) { if (fromDate != null && !fromDate.isBlank()) {
try { try {
where.append(" AND DATE(submitted_at) >= ?"); where.append(" AND DATE(submitted_at) >= ?");
@@ -424,8 +435,63 @@ public class ClerkDisbursementRequestApiResource {
if (!currentUser.getId().equals(clerkId)) { if (!currentUser.getId().equals(clerkId)) {
throw apiError(403, "You can only view your own disbursement requests."); throw apiError(403, "You can only view your own disbursement requests.");
} }
} else {
assertRequestInCallersBranch(record, currentUser);
} }
return gson.toJson(record); return gson.toJson(withLegBranches(record));
}
/**
* The request as stored, with the branch each account leg is held at added to its entries.
*
* <p>
* The approver has to see when money moves between branches, and the stored entries carry only the account number
* and name. The branch is looked up now, from the account, rather than stored at submission: a request raised before
* this existed still shows it, and it is the branch the account is in at the moment of approval that matters. A
* leg with no account behind it (a GL leg) gets none, and a failure here leaves the entries exactly as stored, so
* the review screen never fails over it.
*/
private Map<String, Object> withLegBranches(final Map<?, ?> record) {
final Map<String, Object> out = new java.util.LinkedHashMap<>();
record.forEach((key, value) -> out.put(String.valueOf(key), value));
final Object stored = out.get("credit_entries");
if (!(stored instanceof String) || ((String) stored).isBlank()) {
return out;
}
try {
final JsonArray entries = gson.fromJson((String) stored, JsonArray.class);
for (final JsonElement element : entries) {
final JsonObject entry = element.getAsJsonObject();
final String accountType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
if ("GL".equals(accountType) || !entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) {
continue;
}
final Map<String, Object> branch = branchOfAccount(accountType, entry.get("savingsAccountId").getAsLong());
if (branch != null) {
entry.addProperty("officeId", ((Number) branch.get("id")).longValue());
entry.addProperty("officeName", String.valueOf(branch.get("name")));
}
}
out.put("credit_entries", gson.toJson(entries));
} catch (final RuntimeException e) {
log.warn("Could not add account branches to clerk request {}: {}", record.get("id"), e.getMessage());
}
return out;
}
/**
* The branch an account's books are in: its own office when it was opened at another branch than the client's,
* otherwise the client's (or group's). A CURRENT leg carries an id in m_current_account's id space; every other
* non-GL type is a m_savings_account row.
*/
private Map<String, Object> branchOfAccount(final String accountType, final Long accountId) {
final String sql = "CURRENT".equalsIgnoreCase(accountType)
? "SELECT o.id, o.name FROM m_current_account ca LEFT JOIN m_client c ON c.id = ca.client_id "
+ "JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id) WHERE ca.id = ?"
: "SELECT o.id, o.name FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id "
+ "LEFT JOIN m_group g ON g.id = sa.group_id "
+ "JOIN m_office o ON o.id = COALESCE(sa.office_id, c.office_id, g.office_id) WHERE sa.id = ?";
return jdbcTemplate.queryForList(sql, accountId).stream().findFirst().orElse(null);
} }
// ── POST /v1/clerk/disbursement-requests/{id}/approve ── Manager approves ───── // ── POST /v1/clerk/disbursement-requests/{id}/approve ── Manager approves ─────
@@ -441,6 +507,7 @@ public class ClerkDisbursementRequestApiResource {
final AppUser currentUser = context.authenticatedUser(); final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser); validateCheckerRole(currentUser);
final Map<?, ?> record = loadRecord(id); final Map<?, ?> record = loadRecord(id);
assertRequestInCallersBranch(record, currentUser);
if (!"PENDING".equals(record.get("status"))) { if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status")); throw apiError(409, "Only PENDING requests can be approved. Current status: " + record.get("status"));
@@ -491,6 +558,10 @@ public class ClerkDisbursementRequestApiResource {
rejectRecurringDepositLeg(entry, entryType); rejectRecurringDepositLeg(entry, entryType);
} }
} }
// Re-checked here, not only at submit: another request (or a counter deposit) may have funded the
// same FD while this one waited, and a refusal now still leaves the request PENDING.
final Set<Long> fundedFixedDeposits = validateFixedDepositLegs(creditEntries,
record.get("request_type") != null ? (String) record.get("request_type") : "DISBURSEMENT");
// Atomic PENDING → PROCESSING lock: prevents two managers from concurrently // Atomic PENDING → PROCESSING lock: prevents two managers from concurrently
// posting duplicate Fineract transactions for the same request. // posting duplicate Fineract transactions for the same request.
@@ -567,21 +638,23 @@ public class ClerkDisbursementRequestApiResource {
"No savings-control GL mapping found for account " + acNo "No savings-control GL mapping found for account " + acNo
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2)."); + ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
// Each leg is booked in the branch its own account is held at, not the request's branch.
final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId);
if ("Dr".equals(dbCr)) { if ("Dr".equals(dbCr)) {
final long txnId = isCurrent final long txnId = isCurrent
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) ? directDebitCurrentAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description)
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description); : directDebitSavingsAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(txnId)); txnRefs.add(String.valueOf(txnId));
btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent)); btDebitGlEntries.add(withOffice(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent), legOfficeId));
btTotalDr = btTotalDr.add(entryAmt); btTotalDr = btTotalDr.add(entryAmt);
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId)); if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId)); else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
} else { } else {
final long txnId = isCurrent final long txnId = isCurrent
? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) ? directCreditCurrentAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser); : directCreditSavingsAccount(accountId, entryAmt, legOfficeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(txnId)); txnRefs.add(String.valueOf(txnId));
btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent)); btCreditGlEntries.add(withOffice(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId, isCurrent), legOfficeId));
if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId)); if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId));
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId)); else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
} }
@@ -591,13 +664,10 @@ public class ClerkDisbursementRequestApiResource {
if (!btDebitGlEntries.isEmpty() && !btCreditGlEntries.isEmpty()) { if (!btDebitGlEntries.isEmpty() && !btCreditGlEntries.isEmpty()) {
final String btComment = buildGlEntryComment("Bank Transfer", btDebitGlEntries, description) final String btComment = buildGlEntryComment("Bank Transfer", btDebitGlEntries, description)
+ " → " + buildGlEntryComment("", btCreditGlEntries, null); + " → " + buildGlEntryComment("", btCreditGlEntries, null);
final String btGlTxnId = postCombinedJournalEntry( final String btGlTxnId = postBankTransferJournals(
btDebitGlEntries, btCreditGlEntries, officeId, currencyCode, txnDateStr, btComment); btDebitGlEntries, btCreditGlEntries, officeId, currencyCode, txnDateStr, btComment, undoOps);
if (btGlTxnId != null) { // The request row keeps 60 characters; an inter-branch transfer posts one entry per branch.
linkGlEntriesToSavingsAccounts(btGlTxnId, btDebitGlEntries, 2); approvedGlTxnId = btGlTxnId == null || btGlTxnId.length() <= 60 ? btGlTxnId : btGlTxnId.split(",")[0];
linkGlEntriesToSavingsAccounts(btGlTxnId, btCreditGlEntries, 1);
}
approvedGlTxnId = btGlTxnId;
// Audit log in m_bank_transfer_log // Audit log in m_bank_transfer_log
final String btTxnRefsStr = String.join(",", txnRefs); final String btTxnRefsStr = String.join(",", txnRefs);
@@ -773,6 +843,12 @@ public class ClerkDisbursementRequestApiResource {
} }
} // end else (disbursement/collection mode) } // end else (disbursement/collection mode)
// A funded FD's maturity date and amount were projected off its principal with no money in it;
// the direct credit above does not recompute them, so do it now that the money has landed.
for (final Long fdId : fundedFixedDeposits) {
depositAccountWritePlatformService.recalculateFixedDepositMaturity(fdId);
}
} catch (Exception ex) { } catch (Exception ex) {
// Compensate: undo all posted Fineract transactions in reverse order (best-effort). // Compensate: undo all posted Fineract transactions in reverse order (best-effort).
// This restores account balances even when a later entry fails mid-processing. // This restores account balances even when a later entry fails mid-processing.
@@ -838,6 +914,7 @@ public class ClerkDisbursementRequestApiResource {
final AppUser currentUser = context.authenticatedUser(); final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser); validateCheckerRole(currentUser);
final Map<?, ?> record = loadRecord(id); final Map<?, ?> record = loadRecord(id);
assertRequestInCallersBranch(record, currentUser);
// Maker-checker: the person who submitted the request cannot also reject it. // Maker-checker: the person who submitted the request cannot also reject it.
final Long makerId = record.get("clerk_id") != null final Long makerId = record.get("clerk_id") != null
@@ -988,12 +1065,14 @@ public class ClerkDisbursementRequestApiResource {
"No savings-control GL mapping found for account " + acNo "No savings-control GL mapping found for account " + acNo
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2)."); + ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
// Each leg is booked in the branch its own account is held at, not the request's branch.
final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId);
final long txnId = isCurrent final long txnId = isCurrent
? directDebitCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description) ? directDebitCurrentAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description)
: directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description); : directDebitSavingsAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(txnId)); txnRefs.add(String.valueOf(txnId));
debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent)); debitGlEntries.add(withOffice(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent), legOfficeId));
if (isCurrent) { if (isCurrent) {
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId)); undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
} else { } else {
@@ -1015,11 +1094,12 @@ public class ClerkDisbursementRequestApiResource {
"No savings-control GL mapping found for account " + acNo "No savings-control GL mapping found for account " + acNo
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2)."); + ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
final Long legOfficeId = legOfficeOf(isCurrent, accountId, officeId);
final long txnId = isCurrent final long txnId = isCurrent
? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description) ? directCreditCurrentAccount(accountId, amount, legOfficeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser); : directCreditSavingsAccount(accountId, amount, legOfficeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(txnId)); txnRefs.add(String.valueOf(txnId));
creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent)); creditGlEntries.add(withOffice(new GlEntry(ctrlGl, amount, acNo, null, accountId, isCurrent), legOfficeId));
if (isCurrent) { if (isCurrent) {
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId)); undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
} else { } else {
@@ -1030,13 +1110,8 @@ public class ClerkDisbursementRequestApiResource {
// ── Combined GL entry: DR SavingsControl(per DR) / CR SavingsControl(per CR) ─ // ── Combined GL entry: DR SavingsControl(per DR) / CR SavingsControl(per CR) ─
final String btComment = buildGlEntryComment("Bank Transfer", debitGlEntries, description) final String btComment = buildGlEntryComment("Bank Transfer", debitGlEntries, description)
+ " → " + buildGlEntryComment("", creditGlEntries, null); + " → " + buildGlEntryComment("", creditGlEntries, null);
glTxnId = postCombinedJournalEntry( glTxnId = postBankTransferJournals(
debitGlEntries, creditGlEntries, debitGlEntries, creditGlEntries, officeId, currencyCode, txnDateStr, btComment, undoOps);
officeId, currencyCode, txnDateStr, btComment);
if (glTxnId != null) {
linkGlEntriesToSavingsAccounts(glTxnId, debitGlEntries, 2);
linkGlEntriesToSavingsAccounts(glTxnId, creditGlEntries, 1);
}
} catch (Exception ex) { } catch (Exception ex) {
for (int i = undoOps.size() - 1; i >= 0; i--) { for (int i = undoOps.size() - 1; i >= 0; i--) {
@@ -1223,6 +1298,70 @@ public class ClerkDisbursementRequestApiResource {
} }
} }
/**
* Fixed deposit legs of a clerk request, recognised by id (deposit_type_enum) rather than the entry's label.
* <ul>
* <li>An FD is never debited here: money leaves an FD only through its maturity or premature closure. The
* direct book debit used by Bank Transfer and Collection would otherwise bypass the FD's own refusal.</li>
* <li>An FD credit is its first and only funding, for exactly its principal, and an FD appears once.</li>
* <li>The FDs funded by one request belong to one customer, and every Savings/Current account debited to fund
* them belongs to that customer (or is held jointly) and can pay its amount. A GL source (Disbursement) is
* the bank's own money and has no customer to compare.</li>
* </ul>
*
* @return the ids of the FDs this request funds, for the maturity recalculation after posting
*/
private Set<Long> validateFixedDepositLegs(final JsonArray entries, final String requestType) {
final Set<Long> fdIds = new LinkedHashSet<>();
Long firstFdId = null;
Long fdClientId = null;
for (final JsonElement el : entries) {
final JsonObject entry = el.getAsJsonObject();
final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
if ("GL".equals(type) || "CURRENT".equals(type) || !entry.has("savingsAccountId")
|| entry.get("savingsAccountId").isJsonNull()) {
continue;
}
final Long accountId = entry.get("savingsAccountId").getAsLong();
if (!fixedDepositFundingValidator.isFixedDeposit(accountId)) {
continue;
}
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : String.valueOf(accountId);
if ("Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr")) {
throw apiError(422, "Fixed deposit " + acNo + " cannot be debited. Money leaves a fixed deposit only"
+ " through its maturity or premature closure.");
}
if (!fdIds.add(accountId)) {
throw apiError(422, "Fixed deposit " + acNo + " appears more than once. A fixed deposit is funded once,"
+ " with its full principal in one entry.");
}
final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null);
final Long clientId = fixedDepositFundingValidator.validateFirstFunding(accountId, amount);
if (firstFdId == null) {
firstFdId = accountId;
fdClientId = clientId;
} else if (!Objects.equals(fdClientId, clientId)) {
throw apiError(422, "One " + voucherTypeLabelFor(requestType) + " can fund fixed deposits of one customer"
+ " only. Raise a separate request for each customer.");
}
}
if (fdIds.isEmpty()) {
return fdIds;
}
for (final JsonElement el : entries) {
final JsonObject entry = el.getAsJsonObject();
final String type = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
if (!"Dr".equals(entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr") || "GL".equals(type)
|| !entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) {
continue;
}
final BigDecimal amount = parseBigDecimal(entry.has("amount") ? entry.get("amount").getAsString() : null);
fixedDepositFundingValidator.validateFundingSource(firstFdId, fdClientId, entry.get("savingsAccountId").getAsLong(),
"CURRENT".equals(type), amount);
}
return fdIds;
}
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */ /** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
private static final int SAVINGS_STATUS_ACTIVE = 300; private static final int SAVINGS_STATUS_ACTIVE = 300;
@@ -1365,6 +1504,28 @@ public class ClerkDisbursementRequestApiResource {
} }
} }
/**
* A request belongs to the branch it was raised at, and is the business of that branch's checker, not another's.
* A checker may see and act on requests of their own branch and the branches below it; Head Office, as the root,
* reaches every branch. Anything outside that is reported as not found rather than forbidden, so it does not even
* confirm the request exists. A request with no branch on record belongs to the root.
*/
private void assertRequestInCallersBranch(final Map<?, ?> record, final AppUser user) {
final String callerHierarchy = user.getOffice().getHierarchy();
final Object officeId = record.get("office_id");
boolean visible;
if (officeId == null) {
visible = ".".equals(callerHierarchy);
} else {
final String requestHierarchy = jdbcTemplate.queryForList("SELECT hierarchy FROM m_office WHERE id = ?", String.class,
((Number) officeId).longValue()).stream().findFirst().orElse(null);
visible = requestHierarchy != null && requestHierarchy.startsWith(callerHierarchy);
}
if (!visible) {
throw new NotFoundException("Disbursement request not found: " + record.get("id"));
}
}
private Map<?, ?> loadRecord(final Long id) { private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(""" final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount, SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
@@ -1609,6 +1770,8 @@ public class ClerkDisbursementRequestApiResource {
final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries
final Long savingsAccountId; // numeric savings OR current account ID for GL entity linking; null for GL-only entries final Long savingsAccountId; // numeric savings OR current account ID for GL entity linking; null for GL-only entries
final boolean isCurrentAccount; // true if savingsAccountId names a row in m_current_account, not m_savings_account final boolean isCurrentAccount; // true if savingsAccountId names a row in m_current_account, not m_savings_account
/** The branch this leg's account is held at; null where it was not resolved (the request's branch is then used). */
Long officeId;
GlEntry(final Long glAccountId, final BigDecimal amount, GlEntry(final Long glAccountId, final BigDecimal amount,
final String accountRef, final String txnId) { final String accountRef, final String txnId) {
@@ -2042,6 +2205,105 @@ public class ClerkDisbursementRequestApiResource {
return result != null ? result.getTransactionId() : null; return result != null ? result.getTransactionId() : null;
} }
/** Financial Activity "Liability Transfer": the account that links branches' books on a transfer between them. */
private static final int FINANCIAL_ACTIVITY_LIABILITY_TRANSFER = 200;
/** The branch an account's books are in, or {@code fallbackOfficeId} where it cannot be resolved. */
private Long legOfficeOf(final boolean isCurrent, final Long accountId, final Long fallbackOfficeId) {
final Map<String, Object> branch = branchOfAccount(isCurrent ? "CURRENT" : "SAVINGS", accountId);
return branch != null ? ((Number) branch.get("id")).longValue() : fallbackOfficeId;
}
private static GlEntry withOffice(final GlEntry entry, final Long officeId) {
entry.officeId = officeId;
return entry;
}
private Long liabilityTransferGlId() {
final List<Long> ids = jdbcTemplate.queryForList(
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?",
Long.class, FINANCIAL_ACTIVITY_LIABILITY_TRANSFER);
if (ids.isEmpty()) {
throw new RuntimeException("A transfer between branches needs the Liability Transfer account mapped under "
+ "Accounting > Financial Activity Mappings.");
}
return ids.get(0);
}
private static BigDecimal totalOf(final List<GlEntry> entries) {
BigDecimal total = BigDecimal.ZERO;
for (final GlEntry entry : entries) {
total = total.add(entry.amount);
}
return total;
}
/**
* Posts the ledger side of a bank transfer and returns its transaction id, or the ids comma-separated when more than
* one entry is needed.
*
* <p>
* Every leg belongs to the branch its own account is held at. When they all share one branch this is the single
* combined entry it always was, booked in that branch. When the legs span branches, each branch gets its own entry,
* balanced through the Liability Transfer account: a branch that pays out more than it takes in credits the difference
* to it, one that takes in more debits it. The two sides net to zero across the bank, so each branch's books balance
* by themselves and the bank's total does not move. Each entry registers its own undo, so a failure later in the
* transfer rolls the ledger back with the accounts.
*/
private String postBankTransferJournals(final List<GlEntry> debits, final List<GlEntry> credits, final Long fallbackOfficeId,
final String currencyCode, final String txnDateStr, final String comment, final List<Runnable> undoOps) {
final Map<Long, List<GlEntry>> debitsByOffice = new java.util.LinkedHashMap<>();
final Map<Long, List<GlEntry>> creditsByOffice = new java.util.LinkedHashMap<>();
for (final GlEntry debit : debits) {
debitsByOffice.computeIfAbsent(debit.officeId != null ? debit.officeId : fallbackOfficeId, k -> new ArrayList<>()).add(debit);
}
for (final GlEntry credit : credits) {
creditsByOffice.computeIfAbsent(credit.officeId != null ? credit.officeId : fallbackOfficeId, k -> new ArrayList<>()).add(credit);
}
final Set<Long> offices = new LinkedHashSet<>(debitsByOffice.keySet());
offices.addAll(creditsByOffice.keySet());
final List<String> txnIds = new ArrayList<>();
if (offices.size() == 1) {
postBranchEntry(debits, credits, offices.iterator().next(), currencyCode, txnDateStr, comment, undoOps, txnIds);
} else {
final Long suspenseGlId = liabilityTransferGlId();
for (final Long office : offices) {
final List<GlEntry> branchDebits = new ArrayList<>(debitsByOffice.getOrDefault(office, List.of()));
final List<GlEntry> branchCredits = new ArrayList<>(creditsByOffice.getOrDefault(office, List.of()));
final BigDecimal net = totalOf(branchCredits).subtract(totalOf(branchDebits));
if (net.signum() > 0) {
branchDebits.add(new GlEntry(suspenseGlId, net, "Inter-branch", null));
} else if (net.signum() < 0) {
branchCredits.add(new GlEntry(suspenseGlId, net.negate(), "Inter-branch", null));
}
postBranchEntry(branchDebits, branchCredits, office, currencyCode, txnDateStr,
comment + " | Inter-branch, via Liability Transfer", undoOps, txnIds);
}
}
return txnIds.isEmpty() ? null : String.join(",", txnIds);
}
private void postBranchEntry(final List<GlEntry> debits, final List<GlEntry> credits, final Long officeId, final String currencyCode,
final String txnDateStr, final String comment, final List<Runnable> undoOps, final List<String> txnIds) {
final String glTxnId = postCombinedJournalEntry(debits, credits, officeId, currencyCode, txnDateStr, comment);
if (glTxnId == null) {
return;
}
linkGlEntriesToSavingsAccounts(glTxnId, debits, 2);
linkGlEntriesToSavingsAccounts(glTxnId, credits, 1);
txnIds.add(glTxnId);
undoOps.add(() -> reverseLedgerEntry(glTxnId));
}
/** Reverses a ledger entry this request posted, when a later step of the same transfer fails. */
private void reverseLedgerEntry(final String glTxnId) {
final CommandWrapper cmd = new CommandWrapperBuilder().reverseJournalEntry(glTxnId)
.withJson("{\"comments\":\"Bank transfer rolled back\"}").build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(cmd));
}
/** Builds the GL entry comments string listing all affected account numbers. */ /** Builds the GL entry comments string listing all affected account numbers. */
private String buildGlEntryComment(final String mode, final List<GlEntry> entries, private String buildGlEntryComment(final String mode, final List<GlEntry> entries,
final String description) { final String description) {
@@ -351,8 +351,8 @@ public class ClerkClosureRequestApiResource {
@QueryParam("toDate") final String toDate, @QueryParam("toDate") final String toDate,
@QueryParam("search") final String search) { @QueryParam("search") final String search) {
final StringBuilder where = new StringBuilder("WHERE status = ?"); final StringBuilder where = new StringBuilder("WHERE status = ? AND " + MAKER_IN_HIERARCHY);
final List<Object> baseParams = new ArrayList<>(List.of(status)); final List<Object> baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%"));
if (fromDate != null && !fromDate.isBlank()) { if (fromDate != null && !fromDate.isBlank()) {
where.append(" AND DATE(submitted_at) >= ?"); where.append(" AND DATE(submitted_at) >= ?");
baseParams.add(Date.valueOf(fromDate)); baseParams.add(Date.valueOf(fromDate));
@@ -1071,7 +1071,28 @@ public class ClerkClosureRequestApiResource {
} }
} }
/** Requests whose maker (clerk) sits at or below the caller's office. One parameter: the caller's hierarchy plus "%". */
private static final String MAKER_IN_HIERARCHY = "(SELECT au.office_id FROM m_appuser au WHERE au.id = clerk_id) IN "
+ "(SELECT id FROM m_office WHERE hierarchy LIKE ?)";
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
private void assertMakerInCallersBranch(final Map<?, ?> record) {
final Object makerId = record.get("clerk_id");
final Integer visible = makerId == null ? 0 : jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_appuser au JOIN m_office o ON o.id = au.office_id WHERE au.id = ? AND o.hierarchy LIKE ?",
Integer.class, ((Number) makerId).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
if (visible == null || visible == 0) {
throw new NotFoundException("Request not found: " + record.get("id"));
}
}
private Map<?, ?> loadRecord(final Long id) { private Map<?, ?> loadRecord(final Long id) {
final Map<?, ?> record = loadRecordUnscoped(id);
assertMakerInCallersBranch(record);
return record;
}
private Map<?, ?> loadRecordUnscoped(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(""" final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, savings_account_id, account_no, client_id, client_name, closure_date, SELECT id, savings_account_id, account_no, client_id, client_name, closure_date,
payment_type_id, payment_type_name, description, tok_no, status, payment_type_id, payment_type_name, description, tok_no, status,
@@ -265,7 +265,9 @@ public class ClerkShareWithdrawalRequestApiResource {
@QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit, @QueryParam("offset") @DefaultValue("0") final int offset, @QueryParam("limit") @DefaultValue("20") final int limit,
@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate, @QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate,
@QueryParam("search") final String search) { @QueryParam("search") final String search) {
return queryList("WHERE status = ?", new ArrayList<>(List.of(status)), fromDate, toDate, search, offset, limit); return queryList("WHERE status = ? AND " + MAKER_IN_HIERARCHY,
new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%")), fromDate, toDate, search,
offset, limit);
} }
// ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ────── // ── GET /v1/clerk/share-withdrawal-requests/my ── Clerk's own requests ──────
@@ -1441,7 +1443,29 @@ public class ClerkShareWithdrawalRequestApiResource {
} }
} }
/** Requests whose maker (clerk) sits at or below the caller's office. One parameter: the caller's hierarchy plus "%". */
private static final String MAKER_IN_HIERARCHY = "(SELECT au.office_id FROM m_appuser au WHERE au.id = clerk_id) IN "
+ "(SELECT id FROM m_office WHERE hierarchy LIKE ?)";
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
private void assertMakerInCallersBranch(final Map<?, ?> record) {
final Object makerId = record.get("clerk_id");
final Integer visible = makerId == null ? 0
: jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_appuser au JOIN m_office o ON o.id = au.office_id WHERE au.id = ? AND o.hierarchy LIKE ?",
Integer.class, ((Number) makerId).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
if (visible == null || visible == 0) {
throw new NotFoundException("Request not found: " + record.get("id"));
}
}
private Map<?, ?> loadRecord(final Long id) { private Map<?, ?> loadRecord(final Long id) {
final Map<?, ?> record = loadRecordUnscoped(id);
assertMakerInCallersBranch(record);
return record;
}
private Map<?, ?> loadRecordUnscoped(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate final List<Map<String, Object>> rows = jdbcTemplate
.queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request WHERE id = ?", id); .queryForList("SELECT " + String.join(", ", LIST_COLUMNS) + " FROM m_clerk_share_withdrawal_request WHERE id = ?", id);
if (rows.isEmpty()) { if (rows.isEmpty()) {
@@ -57,6 +57,7 @@ import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage; import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard; import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.savings.service.FixedDepositFundingValidator;
import org.apache.fineract.portfolio.voucher.service.VoucherLine; import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService; import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -85,6 +86,7 @@ public class ClerkWithdrawalRequestApiResource {
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final VoucherService voucherService; private final VoucherService voucherService;
private final FixedDepositFundingValidator fixedDepositFundingValidator;
private final Gson gson = new GsonBuilder().serializeNulls().create(); private final Gson gson = new GsonBuilder().serializeNulls().create();
// ── POST /v1/clerk/withdrawal-requests ── Clerk submits new request ───────── // ── POST /v1/clerk/withdrawal-requests ── Clerk submits new request ─────────
@@ -127,6 +129,12 @@ public class ClerkWithdrawalRequestApiResource {
} else if (savingsAccountId == null) { } else if (savingsAccountId == null) {
throw apiError(400, "Savings account id is required for a withdrawal request"); throw apiError(400, "Savings account id is required for a withdrawal request");
} }
// Money leaves a fixed deposit only through its maturity or premature closure, never a withdrawal slip.
// Checked by id: the slip's own label is not trusted, and a CURRENT id belongs to m_current_account.
if (!isGl && !"CURRENT".equalsIgnoreCase(accountType) && fixedDepositFundingValidator.isFixedDeposit(savingsAccountId)) {
throw apiError(422, "Account " + accountNo + " is a fixed deposit and cannot be withdrawn from."
+ " Money leaves a fixed deposit only through its maturity or premature closure.");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Amount must be greater than zero"); throw apiError(400, "Amount must be greater than zero");
} }
@@ -180,8 +188,8 @@ public class ClerkWithdrawalRequestApiResource {
@QueryParam("toDate") final String toDate, @QueryParam("toDate") final String toDate,
@QueryParam("search") final String search) { @QueryParam("search") final String search) {
final StringBuilder where = new StringBuilder("WHERE status = ?"); final StringBuilder where = new StringBuilder("WHERE status = ? AND " + MAKER_IN_HIERARCHY);
final List<Object> baseParams = new ArrayList<>(List.of(status)); final List<Object> baseParams = new ArrayList<>(List.of(status, context.authenticatedUser().getOffice().getHierarchy() + "%"));
if (fromDate != null && !fromDate.isBlank()) { if (fromDate != null && !fromDate.isBlank()) {
where.append(" AND DATE(submitted_at) >= ?"); where.append(" AND DATE(submitted_at) >= ?");
baseParams.add(Date.valueOf(fromDate)); baseParams.add(Date.valueOf(fromDate));
@@ -300,6 +308,7 @@ public class ClerkWithdrawalRequestApiResource {
if (rows.isEmpty()) { if (rows.isEmpty()) {
throw new NotFoundException("Withdrawal request not found: " + id); throw new NotFoundException("Withdrawal request not found: " + id);
} }
assertMakerInCallersBranch(rows.get(0));
return gson.toJson(rows.get(0)); return gson.toJson(rows.get(0));
} }
@@ -934,7 +943,28 @@ public class ClerkWithdrawalRequestApiResource {
.orElse(Map.of()); .orElse(Map.of());
} }
/** Requests whose maker (clerk) sits at or below the caller's office. One parameter: the caller's hierarchy plus "%". */
private static final String MAKER_IN_HIERARCHY = "(SELECT au.office_id FROM m_appuser au WHERE au.id = clerk_id) IN "
+ "(SELECT id FROM m_office WHERE hierarchy LIKE ?)";
/** A reviewer may open, approve or reject only requests raised in their own branch's hierarchy. */
private void assertMakerInCallersBranch(final Map<?, ?> record) {
final Object makerId = record.get("clerk_id");
final Integer visible = makerId == null ? 0 : jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_appuser au JOIN m_office o ON o.id = au.office_id WHERE au.id = ? AND o.hierarchy LIKE ?",
Integer.class, ((Number) makerId).longValue(), context.authenticatedUser().getOffice().getHierarchy() + "%");
if (visible == null || visible == 0) {
throw new NotFoundException("Request not found: " + record.get("id"));
}
}
private Map<?, ?> loadRecord(final Long id) { private Map<?, ?> loadRecord(final Long id) {
final Map<?, ?> record = loadRecordUnscoped(id);
assertMakerInCallersBranch(record);
return record;
}
private Map<?, ?> loadRecordUnscoped(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(""" final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, account_no, account_type, savings_account_id, client_id, client_name, SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no, amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no,
@@ -0,0 +1,199 @@
/**
* 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.client.api;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
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.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.service.ClientIdentityService;
import org.apache.fineract.portfolio.client.service.ClientIdentityService.IdentityMatch;
import org.apache.fineract.portfolio.savings.security.AccountBranchService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* "Is this person already a customer of the bank?", asked from the General step of client creation before anything is
* saved. See {@link ClientIdentityService} for the rules.
*
* <p>
* It is a plain POST resource rather than a command, so the request body is not written to the command-source audit
* table. The Aadhaar arrives only as its SHA-256; the 12-digit number never reaches the server.
*/
@Path("/v1/clientidentity")
@Component
@Tag(name = "Client Identity", description = "Cross-branch duplicate-customer lookup by PAN, Aadhaar fingerprint or GSTIN.")
@RequiredArgsConstructor
public class ClientIdentityLookupApiResource {
private static final Logger LOG = LoggerFactory.getLogger(ClientIdentityLookupApiResource.class);
private static final Gson GSON = new Gson();
private final PlatformSecurityContext context;
private final ClientIdentityService clientIdentityService;
private final FromJsonHelper fromJsonHelper;
private final AccountBranchService accountBranchService;
/**
* Introduces a customer of another branch to the caller's branch, so the branch can open an account for them
* there. The customer stays at the home branch; this records who introduced them to which branch and when.
*/
@POST
@Path("link")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Link an existing customer of another branch to the caller's branch",
description = "Body: { clientId }. Idempotent. Lets the caller's branch open an account for the customer.")
public String link(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("CREATE_CLIENT", "CREATE_SAVINGSACCOUNT")) {
throw new NoAuthorizationException("The user doesn't have enough permissions to link a customer to a branch.");
}
final JsonElement root = this.fromJsonHelper.parse(StringUtils.defaultIfBlank(apiRequestBodyAsJson, "{}"));
final JsonObject body = root != null && root.isJsonObject() ? root.getAsJsonObject() : new JsonObject();
if (!body.has("clientId") || body.get("clientId").isJsonNull()) {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError("error.msg.clientidentity.clientId.required",
"clientId is required.", "clientId")));
}
final Long clientId = body.get("clientId").getAsLong();
final boolean newlyLinked = this.accountBranchService.linkClientToCallerBranch(clientId);
final Map<String, Object> response = new LinkedHashMap<>();
response.put("clientId", clientId);
response.put("linked", newlyLinked);
response.put("officeId", this.context.authenticatedUser().getOffice().getId());
response.put("officeName", this.context.authenticatedUser().getOffice().getName());
return GSON.toJson(response);
}
@POST
@Path("lookup")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Look up an existing customer by PAN, Aadhaar hash or GSTIN, and check phone/email clashes",
description = "Body: { panNumber?, aadhaarHash?, gstinNumber?, mobileNo?, emailAddress?, excludeClientId? }. "
+ "Any ONE of PAN / Aadhaar hash / GSTIN matching an existing client means the same customer. A phone or "
+ "email clash is reported separately under contactConflicts and is never the same customer.")
public String lookup(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("CREATE_CLIENT", "UPDATE_CLIENT")) {
throw new NoAuthorizationException("The user doesn't have enough permissions to look up customers.");
}
final JsonElement root = this.fromJsonHelper.parse(StringUtils.defaultIfBlank(apiRequestBodyAsJson, "{}"));
final JsonObject body = root != null && root.isJsonObject() ? root.getAsJsonObject() : new JsonObject();
final String pan = text(body, "panNumber");
final String aadhaarHash = text(body, "aadhaarHash");
final String gstin = text(body, "gstinNumber");
final String mobileNo = text(body, "mobileNo");
final String emailAddress = text(body, "emailAddress");
final Long excludeClientId = body.has("excludeClientId") && !body.get("excludeClientId").isJsonNull()
? body.get("excludeClientId").getAsLong() : null;
ClientIdentityService.validateAadhaarHashOrThrow(aadhaarHash);
final List<IdentityMatch> matches = this.clientIdentityService.findMatches(pan, aadhaarHash, gstin, excludeClientId);
final Map<String, Boolean> contactConflicts = this.clientIdentityService.contactConflicts(mobileNo, emailAddress,
excludeClientId);
// Who looked up what, never the values: a lookup that finds a customer at another branch is exactly what
// someone browsing for customers would do, so there has to be a trail.
LOG.info("client identity lookup: user={} fields={} matchedClientIds={}", this.context.authenticatedUser().getUsername(),
suppliedFields(pan, aadhaarHash, gstin, mobileNo, emailAddress), matches.stream().map(IdentityMatch::clientId).toList());
final Set<Long> distinctClients = new LinkedHashSet<>();
matches.forEach(m -> distinctClients.add(m.clientId()));
final Map<String, Object> response = new LinkedHashMap<>();
response.put("matched", !matches.isEmpty());
// PAN points at one customer and Aadhaar (or GSTIN) at another: bad data or fraud, not a customer to reuse.
response.put("conflict", distinctClients.size() > 1);
response.put("matches", toJsonList(matches));
response.put("contactConflicts", contactConflicts);
return GSON.toJson(response);
}
private static String text(final JsonObject body, final String name) {
final JsonElement element = body.get(name);
return element == null || element.isJsonNull() ? null : StringUtils.trimToNull(element.getAsString());
}
private static List<String> suppliedFields(final String pan, final String aadhaarHash, final String gstin, final String mobileNo,
final String emailAddress) {
final List<String> fields = new ArrayList<>();
if (pan != null) {
fields.add("pan");
}
if (aadhaarHash != null) {
fields.add("aadhaar");
}
if (gstin != null) {
fields.add("gstin");
}
if (mobileNo != null) {
fields.add("mobile");
}
if (emailAddress != null) {
fields.add("email");
}
return fields;
}
private static List<Map<String, Object>> toJsonList(final List<IdentityMatch> matches) {
final List<Map<String, Object>> out = new ArrayList<>();
for (final IdentityMatch m : matches) {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("clientId", m.clientId());
row.put("displayName", m.displayName());
row.put("cifNumber", m.cifNumber());
row.put("officeId", m.officeId());
row.put("officeName", m.officeName());
row.put("legalFormEnum", m.legalFormEnum());
row.put("kycStatus", m.kycStatus());
// True when the caller's branch can open this client; false when it sits in another branch's hierarchy.
row.put("visibleToCaller", m.visibleToCaller());
row.put("panMasked", m.panMasked());
row.put("aadhaarMasked", m.aadhaarMasked());
row.put("gstinMasked", m.gstinMasked());
row.put("matchedBy", new ArrayList<>(m.matchedBy()));
out.add(row);
}
return out;
}
}
@@ -28,6 +28,7 @@ import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST; import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam; import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import java.util.List; import java.util.List;
@@ -36,6 +37,7 @@ import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSer
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.KycContactLogData; import org.apache.fineract.portfolio.client.data.KycContactLogData;
import org.apache.fineract.portfolio.client.service.KycContactLogService; import org.apache.fineract.portfolio.client.service.KycContactLogService;
import org.apache.fineract.portfolio.client.service.KycNoticeComposer;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
@@ -65,6 +67,7 @@ public class ClientKycContactLogApiResource {
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final KycContactLogService kycContactLogService; private final KycContactLogService kycContactLogService;
private final KycNoticeComposer noticeComposer;
private final DefaultToApiJsonSerializer<KycContactLogData> toApiJsonSerializer; private final DefaultToApiJsonSerializer<KycContactLogData> toApiJsonSerializer;
@POST @POST
@@ -79,11 +82,31 @@ public class ClientKycContactLogApiResource {
final JsonObject payload = parse(apiRequestBodyAsJson); final JsonObject payload = parse(apiRequestBodyAsJson);
final KycContactLogData result = this.kycContactLogService.logContact(clientId, stringOrNull(payload, "channel"), final KycContactLogData result = this.kycContactLogService.logContact(clientId, stringOrNull(payload, "channel"),
stringOrNull(payload, "notes"), stringOrNull(payload, "officerName")); stringOrNull(payload, "notes"), stringOrNull(payload, "officerName"), stringOrNull(payload, "noticeType"));
return this.toApiJsonSerializer.serialize(result); return this.toApiJsonSerializer.serialize(result);
} }
@GET
@Path("preview")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Preview the notice a customer would receive",
description = "The subject and text for the stage the customer is at today. type is KYC or DORMANCY. "
+ "Empty subject and body when the customer is at no stage of that kind.")
public String previewNotice(@PathParam("clientId") final Long clientId, @QueryParam("type") final String type) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final KycNoticeComposer.KycNotice notice = this.noticeComposer.compose(clientId, type);
final JsonObject json = new JsonObject();
if (notice != null) {
final KycNoticeComposer.KycNotice delivery = this.noticeComposer.forDelivery(notice);
json.addProperty("subject", delivery.subject());
json.addProperty("body", delivery.body());
}
return json.toString();
}
@GET @GET
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@@ -89,4 +89,24 @@ public class ClientKycExceptionsApiResource {
LOG.info("retrieveLapsedKyc: fetched {} lapsed KYC record(s) from database", lapsed.size()); LOG.info("retrieveLapsedKyc: fetched {} lapsed KYC record(s) from database", lapsed.size());
return this.toApiJsonSerializer.serialize(lapsed); return this.toApiJsonSerializer.serialize(lapsed);
} }
/**
* Customers whose periodic re-KYC falls due within the next {@code days} days (default 30).
*
* <p>
* The counterpart of {@code lapsed}: nothing has stopped working for these customers yet, which is
* why they are worth reaching now.
*/
@GET
@Path("expiring")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve KYC records expiring soon", description = "Returns clients whose periodic re-KYC falls due within the next N days (default 30), soonest first.")
public String retrieveExpiringKyc(@QueryParam("days") final Integer days) {
this.context.authenticatedUser();
final int window = days == null ? 30 : days;
final List<ClientKycData> expiring = this.readPlatformService.retrieveExpiringKyc(window);
LOG.info("retrieveExpiringKyc: fetched {} KYC record(s) expiring within {} day(s)", expiring.size(), window);
return this.toApiJsonSerializer.serialize(expiring);
}
} }
@@ -0,0 +1,434 @@
/**
* 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.client.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
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.sql.Timestamp;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Every customer notice on record, across all customers, for audit.
*
* <pre>
* GET /v1/kyc/contact-audit/days one line per day: how many customers and notices, and how they fared
* GET /v1/kyc/contact-audit the notices themselves, a page at a time: {"total": n, "rows": [...]}
* </pre>
*
* <p>
* Both take the same optional filters: {@code from} and {@code to} (yyyy-MM-dd, both inclusive),
* {@code channel}, {@code status} and {@code search} (customer name, account number or reference). The
* per-customer contact log answers "what was this customer told"; this answers "what did the bank send,
* to whom, and did it arrive" — the question an inspection asks.
*
* <p>
* Built for a bank with a very large number of customers: the day view is computed in the database and
* is a few hundred rows at most, and the notices of one day are fetched a page at a time, so nothing
* here ever loads the whole log.
*/
@Path("/v1/kyc/contact-audit")
@Component
@Tag(name = "Client KYC Contact Audit", description = "All customer notices on record.")
@RequiredArgsConstructor
public class KycContactAuditApiResource {
private static final int MAX_PAGE = 500;
private static final int MAX_DAYS = 400;
private static final String FROM_CLAUSE = " FROM m_client_kyc_contact_log l JOIN m_client c ON c.id = l.client_id WHERE 1 = 1";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
@GET
@Path("days")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Notices per day", description = "Newest day first: customers reached, notices sent and their outcomes.")
public String days(@QueryParam("from") final String from, @QueryParam("to") final String to,
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
@QueryParam("search") final String search, @QueryParam("real") final Boolean real) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final List<Object> args = new ArrayList<>();
final String where = filters(from, to, channel, status, search, real, args);
final String sql = "SELECT CAST(l.contacted_at AS DATE) AS d, COUNT(*) AS notices, COUNT(DISTINCT l.client_id) AS customers, "
+ "COUNT(DISTINCT CASE WHEN l.delivery_status = 'SENT' THEN l.client_id END) AS notified, "
+ "SUM(CASE WHEN l.delivery_status = 'SENT' THEN 1 ELSE 0 END) AS delivered, "
+ "SUM(CASE WHEN l.delivery_status = 'FAILED' THEN 1 ELSE 0 END) AS failed, "
+ "SUM(CASE WHEN l.delivery_status = 'SKIPPED_NO_DESTINATION' THEN 1 ELSE 0 END) AS no_contact, "
+ "SUM(CASE WHEN l.delivery_status = 'PROVIDER_NOT_CONFIGURED' THEN 1 ELSE 0 END) AS not_configured, "
+ "SUM(CASE WHEN l.delivery_status = 'NOT_APPLICABLE' THEN 1 ELSE 0 END) AS recorded" + FROM_CLAUSE + where
+ " GROUP BY CAST(l.contacted_at AS DATE) ORDER BY d DESC LIMIT " + MAX_DAYS;
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
final Map<String, Object> item = new LinkedHashMap<>();
item.put("date", String.valueOf(r.get("d")));
item.put("notices", number(r.get("notices")));
item.put("customers", number(r.get("customers")));
// Customers a notice actually reached that day, as against customers it was tried for.
item.put("notifiedCustomers", number(r.get("notified")));
item.put("delivered", number(r.get("delivered")));
item.put("failed", number(r.get("failed")));
item.put("noContact", number(r.get("no_contact")));
item.put("notConfigured", number(r.get("not_configured")));
item.put("recorded", number(r.get("recorded")));
out.add(item);
}
return this.gson.toJson(out);
}
@GET
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Retrieve customer notices on record", description = "Newest first, a page at a time. Returns total and rows.")
public String retrieve(@QueryParam("from") final String from, @QueryParam("to") final String to,
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
@QueryParam("search") final String search, @QueryParam("real") final Boolean real,
@QueryParam("offset") final Integer offset, @QueryParam("limit") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final List<Object> args = new ArrayList<>();
final String where = filters(from, to, channel, status, search, real, args);
final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(*)" + FROM_CLAUSE + where, Long.class, args.toArray());
final int rows = Math.min(Math.max(limit == null ? 50 : limit, 1), MAX_PAGE);
final int skip = Math.max(offset == null ? 0 : offset, 0);
final String sql = "SELECT l.id, l.client_id, c.display_name, c.account_no, l.channel, l.notes, l.officer_name, "
+ "l.delivery_status, l.destination_masked, l.provider, l.provider_reference, l.failure_reason, l.message_body, "
+ "l.contacted_at" + FROM_CLAUSE + where + " ORDER BY l.contacted_at DESC, l.id DESC LIMIT " + rows + " OFFSET " + skip;
final List<Map<String, Object>> list = new ArrayList<>();
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
final Map<String, Object> item = new LinkedHashMap<>();
item.put("id", r.get("id"));
item.put("clientId", r.get("client_id"));
item.put("clientName", r.get("display_name"));
item.put("accountNo", r.get("account_no"));
item.put("channel", r.get("channel"));
item.put("notes", r.get("notes"));
item.put("officerName", r.get("officer_name"));
item.put("deliveryStatus", r.get("delivery_status"));
item.put("destinationMasked", r.get("destination_masked"));
item.put("provider", r.get("provider"));
item.put("providerReference", r.get("provider_reference"));
item.put("failureReason", r.get("failure_reason"));
item.put("messageBody", r.get("message_body"));
final Object at = r.get("contacted_at");
item.put("contactedAt", at == null ? null : String.valueOf(at).replace(' ', 'T'));
list.add(item);
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("total", total == null ? 0 : total);
out.put("rows", list);
return this.gson.toJson(out);
}
/**
* One line per customer: how many notices, how they fared, and the latest. Newest activity first, a page at a time.
* Answers "who was contacted, and what happened" without paging through every day.
*/
@GET
@Path("customers")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Notices per customer", description = "Same filters as the day view. Returns total and rows.")
public String customers(@QueryParam("from") final String from, @QueryParam("to") final String to,
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
@QueryParam("search") final String search, @QueryParam("real") final Boolean real,
@QueryParam("offset") final Integer offset, @QueryParam("limit") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final List<Object> args = new ArrayList<>();
final String where = filters(from, to, channel, status, search, real, args);
final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(DISTINCT l.client_id)" + FROM_CLAUSE + where, Long.class,
args.toArray());
final int rows = Math.min(Math.max(limit == null ? 50 : limit, 1), MAX_PAGE);
final int skip = Math.max(offset == null ? 0 : offset, 0);
final String sql = "SELECT l.client_id, c.display_name, c.account_no, COUNT(*) AS notices, "
+ "SUM(CASE WHEN l.delivery_status = 'SENT' THEN 1 ELSE 0 END) AS delivered, "
+ "SUM(CASE WHEN l.delivery_status = 'FAILED' THEN 1 ELSE 0 END) AS failed, "
+ "SUM(CASE WHEN l.delivery_status IN ('SKIPPED_NO_DESTINATION', 'PROVIDER_NOT_CONFIGURED') THEN 1 ELSE 0 END) AS not_sent, "
+ "SUM(CASE WHEN l.delivery_status = 'NOT_APPLICABLE' THEN 1 ELSE 0 END) AS recorded, "
+ "MAX(l.contacted_at) AS last_at" + FROM_CLAUSE + where
+ " GROUP BY l.client_id, c.display_name, c.account_no ORDER BY last_at DESC, l.client_id DESC LIMIT " + rows + " OFFSET "
+ skip;
final List<Map<String, Object>> list = new ArrayList<>();
final List<Long> pageIds = new ArrayList<>();
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
final Map<String, Object> item = new LinkedHashMap<>();
item.put("clientId", r.get("client_id"));
item.put("clientName", r.get("display_name"));
item.put("accountNo", r.get("account_no"));
item.put("notices", number(r.get("notices")));
item.put("delivered", number(r.get("delivered")));
item.put("failed", number(r.get("failed")));
item.put("notSent", number(r.get("not_sent")));
item.put("recorded", number(r.get("recorded")));
final Object at = r.get("last_at");
item.put("lastAt", at == null ? null : String.valueOf(at).replace(' ', 'T'));
pageIds.add(((Number) r.get("client_id")).longValue());
list.add(item);
}
// The latest notice's channel and outcome, for the customers on this page only.
if (!pageIds.isEmpty()) {
final String marks = String.join(",", pageIds.stream().map(x -> "?").toList());
final Map<Long, Map<String, Object>> byId = new LinkedHashMap<>();
list.forEach((m) -> byId.put(((Number) m.get("clientId")).longValue(), m));
final List<Object> latestArgs = new ArrayList<>(pageIds);
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(
"SELECT client_id, channel, delivery_status FROM m_client_kyc_contact_log WHERE client_id IN (" + marks
+ ") ORDER BY contacted_at DESC, id DESC",
latestArgs.toArray())) {
final Map<String, Object> item = byId.get(((Number) r.get("client_id")).longValue());
if (item != null && !item.containsKey("lastChannel")) {
item.put("lastChannel", r.get("channel"));
item.put("lastStatus", r.get("delivery_status"));
}
}
}
final Map<String, Object> out = new LinkedHashMap<>();
out.put("total", total == null ? 0 : total);
out.put("rows", list);
return this.gson.toJson(out);
}
/**
* What the listed customers have been sent so far: the latest notice and which channels have been done.
* One round trip for a whole screen of customers, so a list can show "last notice" without a call per row.
*/
@GET
@Path("clients")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Notice summary for some customers", description = "ids is a comma-separated list of client ids (up to 500).")
public String clients(@QueryParam("ids") final String ids) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final List<Long> clientIds = new ArrayList<>();
if (StringUtils.isNotBlank(ids)) {
for (final String part : ids.split(",")) {
if (StringUtils.isNumeric(part.trim()) && clientIds.size() < MAX_PAGE) {
clientIds.add(Long.valueOf(part.trim()));
}
}
}
if (clientIds.isEmpty()) {
return this.gson.toJson(new ArrayList<>());
}
final String marks = String.join(",", clientIds.stream().map(x -> "?").toList());
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT client_id, channel, delivery_status, contacted_at, message_body FROM m_client_kyc_contact_log WHERE client_id IN (" + marks
+ ") ORDER BY contacted_at DESC, id DESC",
clientIds.toArray());
final Map<Long, Map<String, Object>> byClient = new LinkedHashMap<>();
// Every customer asked for comes back, with or without notices, so the screen knows who has no mobile or email.
for (final Map<String, Object> c : this.jdbcTemplate.queryForList(
"SELECT c.id, c.mobile_no, c.email_address, "
+ "COALESCE((SELECT SUM(s.account_balance_derived) FROM m_savings_account s WHERE s.client_id = c.id AND s.status_enum = 300), 0) "
+ "+ COALESCE((SELECT SUM(a.account_balance_derived) FROM m_current_account a WHERE a.client_id = c.id AND a.status_enum = 300), 0) AS balance "
+ "FROM m_client c WHERE c.id IN (" + marks + ")", clientIds.toArray())) {
final Long id = ((Number) c.get("id")).longValue();
final Map<String, Object> m = new LinkedHashMap<>();
m.put("clientId", id);
m.put("count", 0);
m.put("lastAt", null);
m.put("lastChannel", null);
m.put("lastStatus", null);
m.put("doneChannels", new ArrayList<String>());
m.put("doneByStage", new LinkedHashMap<String, List<String>>());
m.put("noticesByStage", new LinkedHashMap<String, Integer>());
// What the customer holds in open savings and current accounts.
m.put("balance", c.get("balance"));
m.put("hasMobile", StringUtils.isNotBlank((String) c.get("mobile_no")));
m.put("hasEmail", StringUtils.isNotBlank((String) c.get("email_address")));
// Masked, never the full value: the screen only needs to show that something is on file.
m.put("mobileMasked", maskMobile((String) c.get("mobile_no")));
m.put("emailMasked", maskEmail((String) c.get("email_address")));
byClient.put(id, m);
}
for (final Map<String, Object> r : rows) {
final Long clientId = ((Number) r.get("client_id")).longValue();
final Map<String, Object> item = byClient.computeIfAbsent(clientId, k -> {
final Map<String, Object> m = new LinkedHashMap<>();
m.put("clientId", k);
m.put("count", 0);
m.put("lastAt", null);
m.put("lastChannel", null);
m.put("lastStatus", null);
m.put("doneChannels", new ArrayList<String>());
m.put("doneByStage", new LinkedHashMap<String, List<String>>());
m.put("noticesByStage", new LinkedHashMap<String, Integer>());
return m;
});
if (item.get("lastAt") == null) {
// Rows come newest first, so the first one seen is the latest notice.
final Object at = r.get("contacted_at");
item.put("lastAt", at == null ? null : String.valueOf(at).replace(' ', 'T'));
item.put("lastChannel", r.get("channel"));
item.put("lastStatus", r.get("delivery_status"));
}
item.put("count", ((Integer) item.get("count")) + 1);
// A channel counts as done once it reached the customer, or was recorded (a call or a visit).
final String status = String.valueOf(r.get("delivery_status"));
final String channel = String.valueOf(r.get("channel"));
@SuppressWarnings("unchecked")
final List<String> done = (List<String>) item.get("doneChannels");
if (("SENT".equals(status) || "NOT_APPLICABLE".equals(status)) && !done.contains(channel)) {
done.add(channel);
}
// The same, by the stage the notice was written for, so a notice sent at an earlier stage is not taken for this one.
// A call, a visit or any other record that is not a stage notice is filed under ANY: it counts at whatever stage the customer is at.
final String found = stageOfNotice((String) r.get("message_body"));
final String stage = found == null ? "ANY" : found;
if ("SENT".equals(status) || "NOT_APPLICABLE".equals(status)) {
@SuppressWarnings("unchecked")
final Map<String, List<String>> byStage = (Map<String, List<String>>) item.get("doneByStage");
@SuppressWarnings("unchecked")
final Map<String, Integer> counts = (Map<String, Integer>) item.get("noticesByStage");
counts.merge(stage, 1, Integer::sum);
final List<String> inStage = byStage.computeIfAbsent(stage, k -> new ArrayList<>());
if (!inStage.contains(channel)) {
inStage.add(channel);
}
}
}
return this.gson.toJson(new ArrayList<>(byClient.values()));
}
/** The WHERE tail shared by both views; fills {@code args} in step with its placeholders. */
private String filters(final String from, final String to, final String channel, final String status, final String search,
final Boolean real, final List<Object> args) {
final StringBuilder where = new StringBuilder();
if (Boolean.TRUE.equals(real)) {
// An attempt that went nowhere (no number or address on file, or no gateway set up) is not a notice a customer received.
where.append(" AND l.delivery_status IN ('SENT', 'FAILED', 'NOT_APPLICABLE')");
}
if (StringUtils.isNotBlank(from)) {
where.append(" AND l.contacted_at >= ?");
args.add(Timestamp.valueOf(parse(from, "from").atStartOfDay()));
}
if (StringUtils.isNotBlank(to)) {
where.append(" AND l.contacted_at < ?");
args.add(Timestamp.valueOf(parse(to, "to").plusDays(1).atStartOfDay()));
}
if (StringUtils.isNotBlank(channel)) {
where.append(" AND l.channel = ?");
args.add(channel.trim().toUpperCase());
}
if (StringUtils.isNotBlank(status)) {
where.append(" AND l.delivery_status = ?");
args.add(status.trim().toUpperCase());
}
if (StringUtils.isNotBlank(search)) {
final String like = "%" + search.trim().toLowerCase() + "%";
where.append(" AND (LOWER(c.display_name) LIKE ? OR LOWER(c.account_no) LIKE ? OR LOWER(l.provider_reference) LIKE ?)");
args.add(like);
args.add(like);
args.add(like);
}
return where.toString();
}
/**
* The stage a notice was written for, read from its wording (the composer words every stage differently). Null for a call,
* a visit or any message that is not one of the stage notices.
*/
private static String stageOfNotice(final String body) {
if (StringUtils.isBlank(body)) {
return null;
}
if (body.startsWith("Your KYC expires on")) {
return "EXPIRING";
}
if (body.startsWith("Your KYC expired on")) {
if (body.contains("account is now inoperative")) {
return "INOPERATIVE";
}
return body.contains("debits on your account are now stopped") ? "PARTIAL_FREEZE" : "GRACE";
}
if (body.startsWith("Your account ")) {
if (body.contains("has been closed")) {
return "ESCHEAT";
}
return body.contains("is dormant") ? "DORMANT" : body.contains("is inactive") ? "INACTIVE" : null;
}
return null;
}
private static String maskMobile(final String mobile) {
if (StringUtils.isBlank(mobile)) {
return null;
}
final String digits = mobile.trim();
return digits.length() <= 4 ? digits : "XXXXXX" + digits.substring(digits.length() - 4);
}
private static String maskEmail(final String email) {
if (StringUtils.isBlank(email) || !email.contains("@")) {
return StringUtils.isBlank(email) ? null : email;
}
final String trimmed = email.trim();
final int at = trimmed.indexOf('@');
return trimmed.substring(0, Math.min(1, at)) + "***" + trimmed.substring(at);
}
private static long number(final Object value) {
return value == null ? 0 : ((Number) value).longValue();
}
private LocalDate parse(final String value, final String parameter) {
try {
return LocalDate.parse(value.trim());
} catch (final DateTimeParseException e) {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
"validation.msg.kyc.contact.audit." + parameter + ".invalid", parameter + " must look like 2028-02-17.", parameter)));
}
}
}
@@ -0,0 +1,157 @@
/**
* 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.client.api;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Operation;
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.time.LocalDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.service.KycNoticeQueueService;
import org.apache.fineract.portfolio.client.service.KycNoticeQueueService.EnqueueResult;
import org.apache.fineract.portfolio.client.service.KycNoticeQueueService.QueueItem;
import org.springframework.stereotype.Component;
/**
* Notices queued to be sent in the background.
*
* <pre>
* POST /v1/kyc/notice-queue body: {"items":[{"clientId":1,"noticeType":"KYC","channel":"SMS",
* "accountNo":"..","stageLabel":".."}],
* "notes":"..","officerName":"..","sendAt":"2028-02-17T10:00"}
* GET /v1/kyc/notice-queue?status=PENDING newest first
* POST /v1/kyc/notice-queue/{id}?command=cancel | retry
* </pre>
*
* <p>Authorisation matches the contact log: sending a notice to a customer is a maintenance action on
* the customer's record.
*/
@Path("/v1/kyc/notice-queue")
@Component
@Tag(name = "Client KYC Notice Queue", description = "Queue customer notices to be sent in the background.")
@RequiredArgsConstructor
public class KycNoticeQueueApiResource {
private final PlatformSecurityContext context;
private final KycNoticeQueueService queueService;
private final Gson gson = new Gson();
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Queue notices for background sending")
public String enqueue(final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final JsonObject payload = parse(apiRequestBodyAsJson);
final List<QueueItem> items = new ArrayList<>();
final JsonElement raw = payload.get("items");
if (raw != null && raw.isJsonArray()) {
final JsonArray array = raw.getAsJsonArray();
for (final JsonElement element : array) {
if (!element.isJsonObject()) {
continue;
}
final JsonObject o = element.getAsJsonObject();
final String clientId = string(o, "clientId");
items.add(new QueueItem(clientId == null ? null : Long.valueOf(clientId), string(o, "noticeType"), string(o, "channel"),
string(o, "accountNo"), string(o, "stageLabel")));
}
}
LocalDateTime sendAt = null;
final String sendAtText = string(payload, "sendAt");
if (sendAtText != null && !sendAtText.isBlank()) {
try {
sendAt = LocalDateTime.parse(sendAtText);
} catch (final DateTimeParseException e) {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
"validation.msg.kyc.notice.queue.sendAt.invalid", "sendAt must look like 2028-02-17T10:00.", "sendAt")));
}
}
final EnqueueResult result = this.queueService.enqueue(items, string(payload, "notes"), string(payload, "officerName"), sendAt);
final JsonObject out = new JsonObject();
out.addProperty("queued", result.queued());
out.addProperty("skipped", result.skipped());
out.addProperty("unreachable", result.unreachable());
return out.toString();
}
@GET
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "List queued notices", description = "Newest first. status narrows to PENDING, SENT, FAILED or CANCELLED.")
public String list(@QueryParam("status") final String status, @QueryParam("limit") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
return this.gson.toJson(this.queueService.list(status, limit == null ? 200 : limit));
}
@POST
@Path("{id}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Cancel or retry a queued notice", description = "command=cancel withdraws a notice still waiting; "
+ "command=retry puts a failed one back in line.")
public String act(@PathParam("id") final Long id, @QueryParam("command") final String command) {
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
if ("cancel".equalsIgnoreCase(command)) {
this.queueService.cancel(id);
} else if ("retry".equalsIgnoreCase(command)) {
this.queueService.retry(id);
} else {
throw new PlatformApiDataValidationException(List.of(ApiParameterError
.parameterError("validation.msg.kyc.notice.queue.command.invalid", "command must be cancel or retry.", "command")));
}
final JsonObject out = new JsonObject();
out.addProperty("id", id);
return out.toString();
}
private JsonObject parse(final String json) {
if (json == null || json.isBlank()) {
return new JsonObject();
}
final JsonElement parsed = JsonParser.parseString(json);
return parsed.isJsonObject() ? parsed.getAsJsonObject() : new JsonObject();
}
private String string(final JsonObject o, final String member) {
final JsonElement element = o.get(member);
return element == null || element.isJsonNull() ? null : element.getAsString();
}
}
@@ -33,6 +33,8 @@ public final class ClientKycApiConstants {
public static final String riskCategoryParam = "riskCategory"; public static final String riskCategoryParam = "riskCategory";
public static final String panNumberParam = "panNumber"; public static final String panNumberParam = "panNumber";
public static final String aadhaarNumberMaskedParam = "aadhaarNumberMasked"; public static final String aadhaarNumberMaskedParam = "aadhaarNumberMasked";
/** SHA-256 of the full Aadhaar, computed client-side. The 12-digit number itself is never sent on create/update. */
public static final String aadhaarHashParam = "aadhaarHash";
public static final String gstinNumberParam = "gstinNumber"; public static final String gstinNumberParam = "gstinNumber";
public static final String remarksParam = "remarks"; public static final String remarksParam = "remarks";
public static final String ckycNumberParam = "ckycNumber"; public static final String ckycNumberParam = "ckycNumber";
@@ -118,7 +120,7 @@ public final class ClientKycApiConstants {
// ── Supported parameters for initiate/update ────────────────────────────── // ── Supported parameters for initiate/update ──────────────────────────────
public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList( public static final Set<String> INITIATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
kycTypeParam, riskCategoryParam, panNumberParam, aadhaarNumberMaskedParam, kycTypeParam, riskCategoryParam, panNumberParam, aadhaarNumberMaskedParam, aadhaarHashParam,
gstinNumberParam, remarksParam, "locale", "dateFormat", gstinNumberParam, remarksParam, "locale", "dateFormat",
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam, panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam, kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
@@ -136,7 +138,7 @@ public final class ClientKycApiConstants {
declaredAnnualIncomeParam, declaredAnnualTurnoverParam)); declaredAnnualIncomeParam, declaredAnnualTurnoverParam));
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList( public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam, kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, aadhaarHashParam, gstinNumberParam,
riskCategoryParam, remarksParam, "locale", "dateFormat", riskCategoryParam, remarksParam, "locale", "dateFormat",
panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam, panAadhaarSeedingStatusParam, enterprisePanParam, cinNumberParam, dinNumberParam, incorporationDateParam,
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam, kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
@@ -64,6 +64,8 @@ public final class KycRiskRuleData implements Serializable {
* used in matching; a null maximum means the rule is genuinely open-ended. */ * used in matching; a null maximum means the rule is genuinely open-ended. */
private final BigDecimal txnMinLimitAmount; private final BigDecimal txnMinLimitAmount;
private final BigDecimal txnMaxLimitAmount; private final BigDecimal txnMaxLimitAmount;
/** True when the scenario applies to Cash Credit (overdraft-bearing current) accounts. */
private final boolean ccRelevant;
public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId, public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId,
final Long occupationCategoryCvId, final String occupationCategoryName, final Long occupationCategoryCvId, final String occupationCategoryName,
@@ -76,7 +78,7 @@ public final class KycRiskRuleData implements Serializable {
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag, final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays, final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk, final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) { final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount, final boolean ccRelevant) {
this.id = id; this.id = id;
this.ruleName = ruleName; this.ruleName = ruleName;
this.ruleKind = ruleKind; this.ruleKind = ruleKind;
@@ -106,6 +108,7 @@ public final class KycRiskRuleData implements Serializable {
this.txnThresholdAmountHighRisk = txnThresholdAmountHighRisk; this.txnThresholdAmountHighRisk = txnThresholdAmountHighRisk;
this.txnMinLimitAmount = txnMinLimitAmount; this.txnMinLimitAmount = txnMinLimitAmount;
this.txnMaxLimitAmount = txnMaxLimitAmount; this.txnMaxLimitAmount = txnMaxLimitAmount;
this.ccRelevant = ccRelevant;
} }
public Long getId() { public Long getId() {
@@ -223,4 +226,8 @@ public final class KycRiskRuleData implements Serializable {
public BigDecimal getTxnMaxLimitAmount() { public BigDecimal getTxnMaxLimitAmount() {
return txnMaxLimitAmount; return txnMaxLimitAmount;
} }
public boolean isCcRelevant() {
return ccRelevant;
}
} }
@@ -25,6 +25,7 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.exception.ClientNotActiveException; import org.apache.fineract.portfolio.client.exception.ClientNotActiveException;
import org.apache.fineract.portfolio.client.exception.ClientNotFoundException; import org.apache.fineract.portfolio.client.exception.ClientNotFoundException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -39,6 +40,7 @@ public class ClientRepositoryWrapper {
private final ClientRepository repository; private final ClientRepository repository;
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Client findOneWithNotFoundDetection(final Long id) { public Client findOneWithNotFoundDetection(final Long id) {
@@ -94,12 +96,33 @@ public class ClientRepositoryWrapper {
public Client getClientByClientIdAndHierarchy(final Long clientId, final String hierarchySearchString) { public Client getClientByClientIdAndHierarchy(final Long clientId, final String hierarchySearchString) {
Client client = this.repository.fetchByClientIdAndHierarchy(clientId, hierarchySearchString, hierarchySearchString); Client client = this.repository.fetchByClientIdAndHierarchy(clientId, hierarchySearchString, hierarchySearchString);
// A customer of another branch who has been linked to the caller's branch can be seen here, so the branch can
// open an account for them. Reading only: the write paths do not go through this method.
if (client == null && isLinkedToBranchUnder(clientId, hierarchySearchString)) {
client = this.repository.findById(clientId).orElse(null);
}
if (client == null) { if (client == null) {
throw new ClientNotFoundException(clientId.toString(), "client.id"); throw new ClientNotFoundException(clientId.toString(), "client.id");
} }
return client; return client;
} }
/**
* True when the client has been linked to an office at or below the given hierarchy (a LIKE pattern ending in %), or
* holds an account there. A link is what lets a branch open the customer's page to start an account; the client list
* is stricter and shows the customer only once an account exists.
*/
public boolean isLinkedToBranchUnder(final Long clientId, final String hierarchySearchString) {
final Integer count = this.jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM ("
+ "SELECT l.client_id FROM m_client_branch_link l JOIN m_office lo ON lo.id = l.office_id WHERE l.client_id = ? AND lo.hierarchy LIKE ? "
+ "UNION ALL SELECT sa.client_id FROM m_savings_account sa JOIN m_office ao ON ao.id = sa.office_id WHERE sa.client_id = ? AND ao.hierarchy LIKE ? "
+ "UNION ALL SELECT ca.client_id FROM m_current_account ca JOIN m_office co ON co.id = ca.office_id WHERE ca.client_id = ? AND co.hierarchy LIKE ?"
+ ") t",
Integer.class, clientId, hierarchySearchString, clientId, hierarchySearchString, clientId, hierarchySearchString);
return count != null && count > 0;
}
public Long findIdByExternalId(final ExternalId externalId) { public Long findIdByExternalId(final ExternalId externalId) {
return this.repository.findIdByExternalId(externalId); return this.repository.findIdByExternalId(externalId);
} }
@@ -0,0 +1,52 @@
/**
* 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.client.jobs.processkycnoticequeue;
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 ProcessKycNoticeQueueConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Bean
protected Step processKycNoticeQueueStep(ProcessKycNoticeQueueTasklet processKycNoticeQueueTasklet) {
return new StepBuilder(JobName.PROCESS_KYC_NOTICE_QUEUE.name(), jobRepository)
.tasklet(processKycNoticeQueueTasklet, transactionManager).build();
}
@Bean
public Job processKycNoticeQueueJob(ProcessKycNoticeQueueTasklet processKycNoticeQueueTasklet) {
return new JobBuilder(JobName.PROCESS_KYC_NOTICE_QUEUE.name(), jobRepository)
.start(processKycNoticeQueueStep(processKycNoticeQueueTasklet)).incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,41 @@
/**
* 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.client.jobs.processkycnoticequeue;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.client.service.KycNoticeQueueService;
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;
import org.springframework.stereotype.Component;
/** Sends the notices officers have queued, once they are due. */
@RequiredArgsConstructor
@Component
public class ProcessKycNoticeQueueTasklet implements Tasklet {
private final KycNoticeQueueService kycNoticeQueueService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
kycNoticeQueueService.processDue();
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,52 @@
/**
* 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.client.jobs.sendkycreminders;
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 SendKycRemindersConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Bean
protected Step sendKycRemindersStep(SendKycRemindersTasklet sendKycRemindersTasklet) {
return new StepBuilder(JobName.SEND_KYC_REMINDERS.name(), jobRepository).tasklet(sendKycRemindersTasklet, transactionManager)
.build();
}
@Bean
public Job sendKycRemindersJob(SendKycRemindersTasklet sendKycRemindersTasklet) {
return new JobBuilder(JobName.SEND_KYC_REMINDERS.name(), jobRepository).start(sendKycRemindersStep(sendKycRemindersTasklet))
.incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,44 @@
/**
* 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.client.jobs.sendkycreminders;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.client.service.KycReminderService;
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;
import org.springframework.stereotype.Component;
/**
* Daily pass that tells customers which stage their KYC or account has reached and what happens
* next, by SMS and email.
*/
@RequiredArgsConstructor
@Component
public class SendKycRemindersTasklet implements Tasklet {
private final KycReminderService kycReminderService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
kycReminderService.sendReminders();
return RepeatStatus.FINISHED;
}
}
@@ -29,6 +29,7 @@ import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.codes.domain.CodeValue; import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository; import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -275,7 +276,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
// CBS extended fields // CBS extended fields
clientFamilyMembers.setAadhaarNumber(command.stringValueOfParameterNamed("aadhaarNumber")); clientFamilyMembers.setAadhaarNumber(command.stringValueOfParameterNamed("aadhaarNumber"));
clientFamilyMembers.setPanNumber(command.stringValueOfParameterNamed("panNumber")); clientFamilyMembers.setPanNumber(StringUtils.upperCase(command.stringValueOfParameterNamed("panNumber")));
clientFamilyMembers.setNationality(command.stringValueOfParameterNamed("nationality")); clientFamilyMembers.setNationality(command.stringValueOfParameterNamed("nationality"));
final Boolean isAlive = command.booleanObjectValueOfParameterNamed("isAlive"); final Boolean isAlive = command.booleanObjectValueOfParameterNamed("isAlive");
clientFamilyMembers.setIsAlive(isAlive != null ? isAlive : true); clientFamilyMembers.setIsAlive(isAlive != null ? isAlive : true);
@@ -426,7 +427,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
familyMember.setAadhaarNumber(member.get("aadhaarNumber").getAsString()); familyMember.setAadhaarNumber(member.get("aadhaarNumber").getAsString());
} }
if (member.get("panNumber") != null && !member.get("panNumber").isJsonNull()) { if (member.get("panNumber") != null && !member.get("panNumber").isJsonNull()) {
familyMember.setPanNumber(member.get("panNumber").getAsString()); familyMember.setPanNumber(StringUtils.upperCase(member.get("panNumber").getAsString()));
} }
if (member.get("nationality") != null && !member.get("nationality").isJsonNull()) { if (member.get("nationality") != null && !member.get("nationality").isJsonNull()) {
familyMember.setNationality(member.get("nationality").getAsString()); familyMember.setNationality(member.get("nationality").getAsString());
@@ -585,7 +586,7 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
clientFamilyMember.setAadhaarNumber(command.stringValueOfParameterNamed("aadhaarNumber")); clientFamilyMember.setAadhaarNumber(command.stringValueOfParameterNamed("aadhaarNumber"));
} }
if (command.parameterExists("panNumber")) { if (command.parameterExists("panNumber")) {
clientFamilyMember.setPanNumber(command.stringValueOfParameterNamed("panNumber")); clientFamilyMember.setPanNumber(StringUtils.upperCase(command.stringValueOfParameterNamed("panNumber")));
} }
if (command.parameterExists("nationality")) { if (command.parameterExists("nationality")) {
clientFamilyMember.setNationality(command.stringValueOfParameterNamed("nationality")); clientFamilyMember.setNationality(command.stringValueOfParameterNamed("nationality"));
@@ -0,0 +1,207 @@
/**
* 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.client.service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.AadhaarHash;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Answers "is this person already a customer of the bank?" by identity number, across every branch.
*
* <p>
* PAN, the Aadhaar fingerprint and GSTIN identify a person or an entity, so any ONE of them matching means the same
* customer. Phone and email are only contact details - a match there is a different customer using a number or
* address that is already taken, never the same customer.
*
* <p>
* The lookup deliberately ignores the caller's office hierarchy: the whole point is that a branch must learn that the
* customer is already on the books at another branch. What it hands back is kept to the minimum needed to act on that
* (name, CIF, branch, masked identifiers) and every lookup is logged.
*/
@Service
@RequiredArgsConstructor
public class ClientIdentityService {
/** What a caller is told about an existing customer. Identifiers are masked; nothing here is editable. */
public record IdentityMatch(Long clientId, String displayName, String cifNumber, Long officeId, String officeName,
Integer legalFormEnum, Integer kycStatus, boolean visibleToCaller, String panMasked, String aadhaarMasked,
String gstinMasked, Set<String> matchedBy) {
}
public static final String MATCHED_PAN = "PAN";
public static final String MATCHED_AADHAAR = "AADHAAR";
public static final String MATCHED_GSTIN = "GSTIN";
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
/**
* Every active client whose KYC carries any of the given identifiers. A blank identifier is ignored; if all three
* are blank nothing can match. At most one row per client (a client has one KYC row).
*
* @param excludeClientId a client to leave out (the one being edited), or null
*/
public List<IdentityMatch> findMatches(final String panNumber, final String aadhaarHash, final String gstinNumber,
final Long excludeClientId) {
final String pan = normalizeId(panNumber);
final String hash = AadhaarHash.normalizeOrNull(aadhaarHash);
final String gstin = normalizeId(gstinNumber);
final List<String> conditions = new ArrayList<>();
final List<Object> params = new ArrayList<>();
if (pan != null) {
conditions.add("k.pan_number = ?");
params.add(pan);
}
if (hash != null) {
conditions.add("k.aadhaar_hash = ?");
params.add(hash);
}
if (gstin != null) {
conditions.add("k.gstin_number = ?");
params.add(gstin);
}
if (conditions.isEmpty()) {
return List.of();
}
final StringBuilder sql = new StringBuilder("SELECT c.id, c.display_name, c.cif_number, c.office_id, o.name AS office_name, ")
.append("o.hierarchy AS office_hierarchy, c.legal_form_enum, k.kyc_status, k.pan_number, ")
.append("k.aadhaar_number_masked, k.aadhaar_hash, k.gstin_number ")
.append("FROM m_client_kyc k JOIN m_client c ON c.id = k.client_id JOIN m_office o ON o.id = c.office_id ")
.append("WHERE k.is_deleted = false AND (").append(String.join(" OR ", conditions)).append(")");
if (excludeClientId != null) {
sql.append(" AND c.id <> ?");
params.add(excludeClientId);
}
sql.append(" ORDER BY c.id");
final String callerHierarchy = this.context.officeHierarchy();
return this.jdbcTemplate.query(sql.toString(), (rs, rowNum) -> {
final Set<String> matchedBy = new LinkedHashSet<>();
if (pan != null && pan.equals(rs.getString("pan_number"))) {
matchedBy.add(MATCHED_PAN);
}
if (hash != null && hash.equals(rs.getString("aadhaar_hash"))) {
matchedBy.add(MATCHED_AADHAAR);
}
if (gstin != null && gstin.equals(rs.getString("gstin_number"))) {
matchedBy.add(MATCHED_GSTIN);
}
final String officeHierarchy = rs.getString("office_hierarchy");
// wasNull() only describes the column read immediately before it, so capture it right here.
final int legalForm = rs.getInt("legal_form_enum");
final boolean legalFormMissing = rs.wasNull();
return new IdentityMatch(rs.getLong("id"), rs.getString("display_name"), rs.getString("cif_number"),
rs.getLong("office_id"), rs.getString("office_name"), legalFormMissing ? null : legalForm,
rs.getInt("kyc_status"), officeHierarchy != null && officeHierarchy.startsWith(callerHierarchy),
maskKeepingEnds(rs.getString("pan_number"), 2, 1), rs.getString("aadhaar_number_masked"),
maskKeepingEnds(rs.getString("gstin_number"), 2, 3), matchedBy);
}, params.toArray());
}
/**
* Whether a mobile number or email address is already used by another client. Contact details are unique per
* client, and a hit here is never "the same customer".
*/
public Map<String, Boolean> contactConflicts(final String mobileNo, final String emailAddress, final Long excludeClientId) {
final Map<String, Boolean> conflicts = new LinkedHashMap<>();
conflicts.put("mobileNo", isTaken("mobile_no", StringUtils.trimToNull(mobileNo), excludeClientId));
conflicts.put("emailAddress", isTaken("email_address", StringUtils.trimToNull(emailAddress), excludeClientId));
return conflicts;
}
/**
* Rejects a KYC save that would put an identifier on a second client. Called with only the identifiers being
* added or changed, so editing a client that already shares an identifier with another (possible in data saved
* before this check existed) is not blocked by that old clash.
*/
public void assertNoDuplicate(final Long clientId, final String panNumber, final String aadhaarHash, final String gstinNumber) {
final List<IdentityMatch> matches = findMatches(panNumber, aadhaarHash, gstinNumber, clientId);
if (matches.isEmpty()) {
return;
}
final IdentityMatch other = matches.get(0);
final String where = " (CIF " + other.cifNumber() + ", branch " + other.officeName() + ").";
if (other.matchedBy().contains(MATCHED_PAN)) {
throw new PlatformDataIntegrityException("error.msg.client.kyc.duplicate.panNumber",
"A client with PAN `" + normalizeId(panNumber) + "` already exists" + where
+ " PAN must be unique across clients.", "panNumber", normalizeId(panNumber));
}
if (other.matchedBy().contains(MATCHED_AADHAAR)) {
throw new PlatformDataIntegrityException("error.msg.client.kyc.duplicate.aadhaar",
"A client with this Aadhaar already exists" + where + " Aadhaar must be unique across clients.",
"aadhaarNumber", other.cifNumber());
}
throw new PlatformDataIntegrityException("error.msg.client.kyc.duplicate.gstin",
"A client with GSTIN `" + normalizeId(gstinNumber) + "` already exists" + where
+ " GSTIN must be unique across clients.", "gstinNumber", normalizeId(gstinNumber));
}
/**
* Checks an Aadhaar fingerprint from a request is well formed, as a normal validation error rather than a 500.
* Blank is allowed (no Aadhaar).
*/
public static void validateAadhaarHashOrThrow(final String rawHash) {
if (StringUtils.isNotBlank(rawHash) && !AadhaarHash.isValid(rawHash.trim().toLowerCase(Locale.ROOT))) {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
"error.msg.client.kyc.aadhaarHash.invalid", "aadhaarHash must be a 64-character hex SHA-256 digest.",
"aadhaarHash")));
}
}
/** PAN and GSTIN are stored upper-case; compare them that way. Null for blank. */
public static String normalizeId(final String raw) {
final String trimmed = StringUtils.trimToNull(raw);
return trimmed == null ? null : trimmed.toUpperCase(Locale.ROOT);
}
private boolean isTaken(final String column, final String value, final Long excludeClientId) {
if (value == null) {
return false;
}
// column is one of two fixed names above, never caller input.
final String sql = "SELECT COUNT(1) FROM m_client WHERE " + column + " = ?" + (excludeClientId != null ? " AND id <> ?" : "");
final Integer count = excludeClientId != null ? this.jdbcTemplate.queryForObject(sql, Integer.class, value, excludeClientId)
: this.jdbcTemplate.queryForObject(sql, Integer.class, value);
return count != null && count > 0;
}
/** Keeps the first {@code head} and last {@code tail} characters, e.g. PAN ABCDE1234F -> AB*******F. */
private static String maskKeepingEnds(final String value, final int head, final int tail) {
if (value == null || value.length() <= head + tail) {
return value == null ? null : "*".repeat(value.length());
}
return value.substring(0, head) + "*".repeat(value.length() - head - tail) + value.substring(value.length() - tail);
}
}
@@ -45,4 +45,14 @@ public interface ClientKycReadPlatformService {
* included: nothing is owed on it yet. * included: nothing is owed on it yet.
*/ */
java.util.List<ClientKycData> retrieveLapsedKyc(); java.util.List<ClientKycData> retrieveLapsedKyc();
/**
* KYC records whose re-KYC falls due within the next {@code days} days, soonest first.
*
* <p>
* Nothing is owed on these yet, but they are the customers the branch can still reach before the
* restriction ladder starts. A record that has already lapsed is in {@link #retrieveLapsedKyc()}
* instead.
*/
java.util.List<ClientKycData> retrieveExpiringKyc(int days);
} }
@@ -173,4 +173,13 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.map(this::toData) .map(this::toData)
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
} }
@Override
public java.util.List<ClientKycData> retrieveExpiringKyc(final int days) {
final java.time.LocalDate today = DateUtils.getBusinessLocalDate();
return this.kycRepository
.findExpiringBetween(today, today.plusDays(Math.max(days, 0)), ClientKycStatus.REJECTED.getValue()).stream()
.map(this::toData)
.collect(java.util.stream.Collectors.toList());
}
} }
@@ -77,6 +77,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
private final org.apache.fineract.portfolio.client.ckyc.CkycSftpUploadService ckycSftpUploadService; private final org.apache.fineract.portfolio.client.ckyc.CkycSftpUploadService ckycSftpUploadService;
private final SavingsAccountRepository savingsAccountRepository; private final SavingsAccountRepository savingsAccountRepository;
private final org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService documentWritePlatformService; private final org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService documentWritePlatformService;
private final ClientIdentityService clientIdentityService;
/** Hard cap on uploaded CKYC ZIP size to keep the byte[] copy bounded (50 MB). */ /** Hard cap on uploaded CKYC ZIP size to keep the byte[] copy bounded (50 MB). */
private static final int CKYC_ZIP_MAX_BYTES = 50 * 1024 * 1024; private static final int CKYC_ZIP_MAX_BYTES = 50 * 1024 * 1024;
@@ -85,7 +86,8 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
@Override @Override
public CommandProcessingResult initiateKyc(final Long clientId, final JsonCommand command) { public CommandProcessingResult initiateKyc(final Long clientId, final JsonCommand command) {
this.context.authenticatedUser(); this.context.authenticatedUser();
LOG.info("initiateKyc: clientId={}, requestJson={}", clientId, command.json()); LOG.info("initiateKyc: clientId={}, requestJson={}", clientId,
command.json().replaceAll("\"aadhaarHash\"\\s*:\\s*\"[0-9a-fA-F]*\"", "\"aadhaarHash\":\"<redacted>\""));
final Client clientForValidation = clientRepository.findOneWithNotFoundDetection(clientId); final Client clientForValidation = clientRepository.findOneWithNotFoundDetection(clientId);
@@ -108,6 +110,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
.build(); .build();
} }
// PAN, Aadhaar and GSTIN identify one customer for the whole bank. The create-client request used to be the
// only place this was checked, but the wizard saves the KYC through this call, so a duplicate got through.
final String aadhaarHashInRequest = command.stringValueOfParameterNamed(ClientKycApiConstants.aadhaarHashParam);
ClientIdentityService.validateAadhaarHashOrThrow(aadhaarHashInRequest);
this.clientIdentityService.assertNoDuplicate(clientId, command.stringValueOfParameterNamed(ClientKycApiConstants.panNumberParam),
aadhaarHashInRequest, command.stringValueOfParameterNamed(ClientKycApiConstants.gstinNumberParam));
try { try {
final Client client = clientForValidation; final Client client = clientForValidation;
final ClientKyc kyc = ClientKyc.initiateKyc(client, command); final ClientKyc kyc = ClientKyc.initiateKyc(client, command);
@@ -145,6 +154,18 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final ClientKycStatus currentStatus = kyc.getKycStatus(); final ClientKycStatus currentStatus = kyc.getKycStatus();
// Check only identifiers this request adds or changes. A client that already shares one with another client
// (saved before this check existed) must still be editable.
final String hashInRequest = command.stringValueOfParameterNamed(ClientKycApiConstants.aadhaarHashParam);
ClientIdentityService.validateAadhaarHashOrThrow(hashInRequest);
final String newPan = command.isChangeInStringParameterNamed(ClientKycApiConstants.panNumberParam, kyc.getPanNumber())
? command.stringValueOfParameterNamed(ClientKycApiConstants.panNumberParam) : null;
final String newGstin = command.isChangeInStringParameterNamed(ClientKycApiConstants.gstinNumberParam, kyc.getGstinNumber())
? command.stringValueOfParameterNamed(ClientKycApiConstants.gstinNumberParam) : null;
final String newHash = StringUtils.isNotBlank(hashInRequest) && !hashInRequest.trim().equalsIgnoreCase(kyc.getAadhaarHash())
? hashInRequest : null;
this.clientIdentityService.assertNoDuplicate(clientId, newPan, newHash, newGstin);
final Map<String, Object> changes = kyc.update(command); final Map<String, Object> changes = kyc.update(command);
// When re-editing a VERIFIED record, remarks are mandatory (audit trail) — but only for a // When re-editing a VERIFIED record, remarks are mandatory (audit trail) — but only for a
@@ -109,11 +109,18 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
// this.context.validateAccessRights(searchParameters.getHierarchy()); // this.context.validateAccessRights(searchParameters.getHierarchy());
// underHierarchySearchString = searchParameters.getHierarchy() + "%"; // underHierarchySearchString = searchParameters.getHierarchy() + "%";
// } // }
List<Object> paramList = new ArrayList<>(Arrays.asList(underHierarchySearchString, underHierarchySearchString)); List<Object> paramList = new ArrayList<>(Arrays.asList(underHierarchySearchString, underHierarchySearchString,
underHierarchySearchString, underHierarchySearchString));
final StringBuilder sqlBuilder = new StringBuilder(200); final StringBuilder sqlBuilder = new StringBuilder(200);
sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " "); sqlBuilder.append("select " + sqlGenerator.calcFoundRows() + " ");
sqlBuilder.append(this.clientToDataMapper.schema()); sqlBuilder.append(this.clientToDataMapper.schema());
sqlBuilder.append(" where (o.hierarchy like ? or transferToOffice.hierarchy like ?) "); // The last two alternatives: a customer of another branch who holds an account at this branch. Being linked to a
// branch is not enough to be listed there; the customer appears once an account is actually opened.
sqlBuilder.append(" where (o.hierarchy like ? or transferToOffice.hierarchy like ?"
+ " or c.id in (select sa.client_id from m_savings_account sa join m_office ao on ao.id = sa.office_id"
+ " where sa.client_id is not null and ao.hierarchy like ?)"
+ " or c.id in (select ca.client_id from m_current_account ca join m_office co on co.id = ca.office_id"
+ " where ca.client_id is not null and co.hierarchy like ?)) ");
if (searchParameters != null) { if (searchParameters != null) {
if (searchParameters.getIsSelfUser()) { if (searchParameters.getIsSelfUser()) {
@@ -274,7 +274,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
aadhaar = this.fromApiJsonHelper.extractStringNamed( aadhaar = this.fromApiJsonHelper.extractStringNamed(
org.apache.fineract.portfolio.client.data.ClientKycApiConstants.aadhaarNumberParam, kyc); org.apache.fineract.portfolio.client.data.ClientKycApiConstants.aadhaarNumberParam, kyc);
} }
if (StringUtils.isNotBlank(aadhaar)) { // "XXXXXXXX0000" is the placeholder the wizard sends for entities and for clients with no Aadhaar. It is not a
// number, so every such client would "match" every other one.
if (StringUtils.isNotBlank(aadhaar) && !"XXXXXXXX0000".equals(aadhaar)) {
final String masked = aadhaar.length() == 12 ? "XXXXXXXX" + aadhaar.substring(8) : aadhaar; final String masked = aadhaar.length() == 12 ? "XXXXXXXX" + aadhaar.substring(8) : aadhaar;
final boolean clash = excludeClientId == null final boolean clash = excludeClientId == null
? clientKycRepository.existsByAadhaarMasked(masked) ? clientKycRepository.existsByAadhaarMasked(masked)
@@ -42,6 +42,37 @@ public interface KycContactLogService {
*/ */
KycContactLogData logContact(Long clientId, String rawChannel, String notes, String officerName); KycContactLogData logContact(Long clientId, String rawChannel, String notes, String officerName);
/**
* As {@link #logContact(Long, String, String, String)}, but a sending channel carries the notice for the stage
* the customer is at today ({@code noticeType} {@code KYC} or {@code DORMANCY}) instead of the generic
* "KYC pending" text. The stage is read from the customer's own record, not from the caller.
*
* @param noticeType {@code KYC}, {@code DORMANCY}, or blank for the generic notice
*/
KycContactLogData logContact(Long clientId, String rawChannel, String notes, String officerName, String noticeType);
/**
* Sends a notice whose wording the caller supplies, and logs it exactly as {@link #logContact} would.
*
* <p>Used by the nightly reminder job, which knows which stage the customer is at and what happens
* next, and says so in the message — the generic "KYC pending" text does not. Only the sending
* channels (SMS, Email, WhatsApp) are accepted. The bank's name is appended to {@code body}.
*
* @param notes recorded against the log; the caller starts it with a marker so a reminder is not
* repeated
* @param subject email subject (without the bank name)
* @param body plain-text message
*/
KycContactLogData sendNotice(Long clientId, String rawChannel, String notes, String officerName, String subject, String body);
/**
* Sends the notice for the stage the customer is at today and logs it, without needing a logged-in user —
* for the queue job, which sends what an officer queued earlier.
*
* @param noticeType {@code KYC} or {@code DORMANCY}
*/
KycContactLogData sendStageNotice(Long clientId, String rawChannel, String notes, String officerName, String noticeType);
/** All contact attempts for one customer, oldest first. */ /** All contact attempts for one customer, oldest first. */
List<KycContactLogData> retrieveForClient(Long clientId); List<KycContactLogData> retrieveForClient(Long clientId);
} }
@@ -42,6 +42,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@Service @Service
@@ -64,21 +65,30 @@ public class KycContactLogServiceImpl implements KycContactLogService {
private final CustomerNotificationDispatcher dispatcher; private final CustomerNotificationDispatcher dispatcher;
private final EmailTemplateService emailTemplateService; private final EmailTemplateService emailTemplateService;
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final KycNoticeComposer noticeComposer;
public KycContactLogServiceImpl(final ClientRepositoryWrapper clientRepositoryWrapper, final ClientKycRepository kycRepository, public KycContactLogServiceImpl(final ClientRepositoryWrapper clientRepositoryWrapper, final ClientKycRepository kycRepository,
final ClientKycContactLogRepository contactLogRepository, final CustomerNotificationDispatcher dispatcher, final ClientKycContactLogRepository contactLogRepository, final CustomerNotificationDispatcher dispatcher,
final EmailTemplateService emailTemplateService, final PlatformSecurityContext context) { final EmailTemplateService emailTemplateService, final PlatformSecurityContext context,
final KycNoticeComposer noticeComposer) {
this.clientRepositoryWrapper = clientRepositoryWrapper; this.clientRepositoryWrapper = clientRepositoryWrapper;
this.kycRepository = kycRepository; this.kycRepository = kycRepository;
this.contactLogRepository = contactLogRepository; this.contactLogRepository = contactLogRepository;
this.dispatcher = dispatcher; this.dispatcher = dispatcher;
this.emailTemplateService = emailTemplateService; this.emailTemplateService = emailTemplateService;
this.context = context; this.context = context;
this.noticeComposer = noticeComposer;
}
@Override
public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName) {
return logContact(clientId, rawChannel, notes, officerName, null);
} }
@Override @Override
@Transactional @Transactional
public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName) { public KycContactLogData logContact(final Long clientId, final String rawChannel, final String notes, final String officerName,
final String noticeType) {
final KycContactChannel channel = KycContactChannel.fromString(rawChannel); final KycContactChannel channel = KycContactChannel.fromString(rawChannel);
if (channel == null) { if (channel == null) {
throw validationError("channel", "Unrecognised contact channel: " + rawChannel); throw validationError("channel", "Unrecognised contact channel: " + rawChannel);
@@ -99,11 +109,22 @@ public class KycContactLogServiceImpl implements KycContactLogService {
currentUser != null ? currentUser.getId() : null, exceptionReason, now); currentUser != null ? currentUser.getId() : null, exceptionReason, now);
if (channel.isPlatformSent()) { if (channel.isPlatformSent()) {
final String subject = "Action Required: KYC Verification Pending - " + this.bankName; String subject = "Action Required: KYC Verification Pending - " + this.bankName;
final String plainBody = buildPlainBody(client, exceptionReason, channel); String plainBody = buildPlainBody(client, exceptionReason, channel);
String htmlTitle = "KYC Verification Pending";
if (StringUtils.isNotBlank(noticeType)) {
final KycNoticeComposer.KycNotice stage = this.noticeComposer.compose(clientId, noticeType);
if (stage == null) {
throw validationError("noticeType", "This customer is not at a stage that has a notice: " + noticeType);
}
final KycNoticeComposer.KycNotice delivery = this.noticeComposer.forDelivery(stage);
subject = delivery.subject();
plainBody = delivery.body();
htmlTitle = stage.subject();
}
// Only email renders HTML; SMS/WhatsApp take the plain text and would show markup verbatim. // Only email renders HTML; SMS/WhatsApp take the plain text and would show markup verbatim.
final String htmlBody = channel == KycContactChannel.EMAIL final String htmlBody = channel == KycContactChannel.EMAIL
? this.emailTemplateService.renderAlert(displayName(client), "KYC Verification Pending", plainBody) ? this.emailTemplateService.renderAlert(displayName(client), htmlTitle, plainBody)
: null; : null;
final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, subject, plainBody, htmlBody); final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, subject, plainBody, htmlBody);
@@ -122,6 +143,63 @@ public class KycContactLogServiceImpl implements KycContactLogService {
return toData(saved); return toData(saved);
} }
/**
* Its own transaction, so a notice that cannot be sent rolls back only itself. Joined to the queue job's
* transaction instead, one failure would mark the whole run rollback-only and every queued notice would
* stay waiting for ever.
*/
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public KycContactLogData sendStageNotice(final Long clientId, final String rawChannel, final String notes, final String officerName,
final String noticeType) {
final KycNoticeComposer.KycNotice stage = this.noticeComposer.compose(clientId, noticeType);
if (stage == null) {
throw validationError("noticeType", "The customer is no longer at a stage that has a notice: " + noticeType);
}
return sendNotice(clientId, rawChannel, notes, officerName, stage.subject(), stage.body());
}
@Override
@Transactional
public KycContactLogData sendNotice(final Long clientId, final String rawChannel, final String notes, final String officerName,
final String subject, final String body) {
final KycContactChannel channel = KycContactChannel.fromString(rawChannel);
if (channel == null || !channel.isPlatformSent()) {
throw validationError("channel", "Only SMS, Email and WhatsApp can be sent: " + rawChannel);
}
if (StringUtils.isBlank(notes) || StringUtils.isBlank(body)) {
throw validationError("notes", "A note and a message are required.");
}
final Client client = this.clientRepositoryWrapper.findOneWithNotFoundDetection(clientId);
final ClientKyc kyc = this.kycRepository.findByClientId(clientId).orElse(null);
final String exceptionReason = kyc != null ? kyc.getExceptionReason() : null;
// A scheduled job has no logged-in user, so the user is optional here rather than required.
final AppUser currentUser = this.context.getAuthenticatedUserIfPresent();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
final ClientKycContactLog log = new ClientKycContactLog(client, kyc != null ? kyc.getId() : null, channel,
StringUtils.abbreviate(notes.trim(), NOTES_MAX_LENGTH), officerName,
currentUser != null ? currentUser.getId() : null, exceptionReason, now);
final String fullSubject = subject + " - " + this.bankName;
final String plainBody = body.trim() + " - " + this.bankName;
final String htmlBody = channel == KycContactChannel.EMAIL
? this.emailTemplateService.renderAlert(displayName(client), subject, plainBody)
: null;
final CustomerNotificationResult result = this.dispatcher.dispatch(channel, client, fullSubject, plainBody, htmlBody);
log.recordDeliveryOutcome(result.status(), result.destinationMasked(), result.provider(), result.providerReference(),
result.failureReason(), plainBody);
if (!result.isDelivered()) {
LOG.warn("Automatic notice to client {} via {} was not delivered — status={}, reason={}", clientId, channel, result.status(),
result.failureReason());
}
return toData(this.contactLogRepository.saveAndFlush(log));
}
@Override @Override
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<KycContactLogData> retrieveForClient(final Long clientId) { public List<KycContactLogData> retrieveForClient(final Long clientId) {
@@ -0,0 +1,194 @@
/**
* 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.client.service;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Locale;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.apache.fineract.portfolio.savings.data.DormantAccountData;
import org.apache.fineract.portfolio.savings.service.DormantAccountReadPlatformService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* The one place that decides what a customer is told, for each stage they can be at.
*
* <p>
* Kept apart from both the nightly reminder job and the officer's Notify button so the two can never
* word the same stage differently — and apart from the contact-log service so neither of them depends
* on the other.
*
* <p>
* The stage is worked out from the customer's own record on the day the notice is composed, not taken
* from the caller. A screen that is a few minutes stale therefore cannot send a notice for a stage the
* customer has already left.
*/
@Component
public class KycNoticeComposer {
/** The two families of notice: a re-KYC notice, or one about an account that has gone quiet. */
public static final String TYPE_KYC = "KYC";
public static final String TYPE_DORMANCY = "DORMANCY";
public static final int DEBIT_FREEZE_DAYS = 90;
public static final int INOPERATIVE_DAYS = 180;
public static final int SUB_STATUS_INACTIVE = 100;
public static final int SUB_STATUS_DORMANT = 200;
public static final int SUB_STATUS_ESCHEAT = 300;
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.ENGLISH);
/** Subject and plain-text body of one notice. */
public record KycNotice(String subject, String body) {
}
private final ClientKycRepository kycRepository;
private final DormantAccountReadPlatformService dormantAccountReadPlatformService;
@Value("${fineract.email.from-name:CBSnexora}")
private String bankName;
public KycNoticeComposer(final ClientKycRepository kycRepository,
final DormantAccountReadPlatformService dormantAccountReadPlatformService) {
this.kycRepository = kycRepository;
this.dormantAccountReadPlatformService = dormantAccountReadPlatformService;
}
/**
* The notice for the stage this customer is at today, or {@code null} when they are at none of the
* stages of that family (no KYC expiry on record, or no inactive / dormant / escheated account).
*/
public KycNotice compose(final Long clientId, final String type) {
if (TYPE_KYC.equalsIgnoreCase(type)) {
final ClientKyc kyc = kycRepository.findByClientId(clientId).orElse(null);
if (kyc == null || kyc.getExpiryDate() == null) {
return null;
}
final long offset = ChronoUnit.DAYS.between(kyc.getExpiryDate(), DateUtils.getBusinessLocalDate());
return new KycNotice(kycSubject(offset), kycMessage(kyc.getExpiryDate(), offset));
}
if (TYPE_DORMANCY.equalsIgnoreCase(type)) {
DormantAccountData worst = null;
for (final DormantAccountData account : dormantAccountReadPlatformService.retrieveDormantAccounts(null, null)) {
if (clientId.equals(account.getClientId()) && account.getSubStatus() != null
&& (worst == null || idle(account) > idle(worst))) {
worst = account;
}
}
return worst == null ? null
: new KycNotice(dormantSubject(worst), dormantMessage(worst, DateUtils.getBusinessLocalDate()));
}
return null;
}
/** The notice as it reaches the customer: the bank's name added to the subject and the sign-off. */
public KycNotice forDelivery(final KycNotice notice) {
return new KycNotice(notice.subject() + " - " + bankName, notice.body() + " - " + bankName);
}
// ── Re-KYC ─────────────────────────────────────────────────────────────────
public static String kycSubject(final long offset) {
if (offset < 0) {
return "Your KYC is due for renewal";
}
if (offset < DEBIT_FREEZE_DAYS) {
return "Your KYC has expired";
}
if (offset < INOPERATIVE_DAYS) {
return "Debits on your account are stopped";
}
return "Your account is inoperative";
}
/** What the customer is told, by the stage they are at: where they stand and what happens next, and in how many days. */
public static String kycMessage(final LocalDate expiry, final long offset) {
final String expired = expiry.format(DATE);
final String freeze = expiry.plusDays(DEBIT_FREEZE_DAYS).format(DATE);
final String inoperative = expiry.plusDays(INOPERATIVE_DAYS).format(DATE);
final String freezeIn = days(DEBIT_FREEZE_DAYS - offset);
final String inoperativeIn = days(INOPERATIVE_DAYS - offset);
if (offset < 0) {
return "Your KYC expires on " + expired + " (in " + days(-offset) + "). Please visit your branch with valid ID and address"
+ " proof to renew it. If it is not renewed, debits on your account will stop on " + freeze + " (in " + freezeIn
+ ") and the account will become inoperative on " + inoperative + " (in " + inoperativeIn + ").";
}
if (offset < DEBIT_FREEZE_DAYS) {
return "Your KYC expired on " + expired + ". Please renew it at your branch now. If it is not renewed, debits on your"
+ " account will stop on " + freeze + " (in " + freezeIn + ") and the account will become inoperative on "
+ inoperative + " (in " + inoperativeIn + ").";
}
if (offset < INOPERATIVE_DAYS) {
return "Your KYC expired on " + expired + " and debits on your account are now stopped. Please renew your KYC at your"
+ " branch; if it is not renewed, the account will become inoperative on " + inoperative + " (in " + inoperativeIn
+ ").";
}
return "Your KYC expired on " + expired + " and your account is now inoperative. Please visit your branch with valid"
+ " documents to renew your KYC and have the account restored.";
}
private static String days(final long n) {
return n == 1 ? "1 day" : n + " days";
}
// ── Inactive / dormant / escheated accounts ────────────────────────────────
public static String dormantSubject(final DormantAccountData account) {
final Integer sub = account.getSubStatus();
if (sub != null && sub == SUB_STATUS_ESCHEAT) {
return "Your account has been closed";
}
return sub != null && sub == SUB_STATUS_DORMANT ? "Your account is dormant" : "Your account is inactive";
}
public static String dormantMessage(final DormantAccountData account, final LocalDate today) {
final String since = account.getLastCustomerTransactionDate() != null ? account.getLastCustomerTransactionDate().format(DATE)
: "a long time";
final Integer sub = account.getSubStatus();
if (sub != null && sub == SUB_STATUS_ESCHEAT) {
return "Your account " + account.getAccountNo() + " has been closed after a very long period with no transaction by you"
+ " (last on " + since + "), and the balance has been transferred to RBI's Depositor Education and Awareness Fund."
+ " You can still claim it: please visit your branch with valid ID proof.";
}
if (sub != null && sub == SUB_STATUS_DORMANT) {
final String escheat = account.getDaysToEscheat() != null && account.getDaysToEscheat() > 0
? " If it stays dormant, the balance will be transferred to RBI's Depositor Education and Awareness Fund on "
+ today.plusDays(account.getDaysToEscheat()).format(DATE) + " (in " + days(account.getDaysToEscheat()) + ")."
: "";
return "Your account " + account.getAccountNo() + " is dormant: there has been no transaction by you since " + since
+ ", so debits are stopped. To reactivate it, please visit your branch with valid KYC documents to verify your"
+ " identity." + escheat;
}
final String dormant = account.getDaysToDormancy() != null && account.getDaysToDormancy() > 0
? " If there is still no transaction, it will become dormant on " + today.plusDays(account.getDaysToDormancy()).format(DATE)
+ " (in " + days(account.getDaysToDormancy()) + ") and debits will be stopped."
: "";
return "Your account " + account.getAccountNo() + " is inactive: there has been no transaction by you since " + since
+ ". Please make a transaction or visit your branch." + dormant;
}
private static long idle(final DormantAccountData account) {
return account.getDaysIdle() == null ? 0 : account.getDaysIdle();
}
}
@@ -0,0 +1,68 @@
/**
* 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.client.service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* Notices an officer has asked to be sent in the background.
*
* <p>
* The officer picks customers and submits; the notices wait in the queue until the queue job sends
* them. Each is sent exactly as an immediate Notify would be — the wording is for the stage the
* customer is at when the notice goes out, and it is recorded in the customer's contact log.
*/
public interface KycNoticeQueueService {
/** One customer and channel to notify. {@code noticeType} is KYC or DORMANCY. */
record QueueItem(Long clientId, String noticeType, String channel, String accountNo, String stageLabel) {
}
/**
* How many were queued, how many were left out because the same notice was already waiting, and how many because the
* customer has no mobile number or email address to send it to.
*/
record EnqueueResult(int queued, int skipped, int unreachable) {
}
/**
* Queues one notice per item.
*
* @param sendAt when to send; {@code null} means as soon as the queue job next runs
*/
EnqueueResult enqueue(List<QueueItem> items, String notes, String officerName, LocalDateTime sendAt);
/** Queue rows, newest first. {@code status} narrows to one of PENDING, SENT, FAILED, CANCELLED. */
List<Map<String, Object>> list(String status, int limit);
/** Withdraws a notice that has not been sent yet. */
void cancel(Long id);
/** Puts a failed notice back to be sent again. */
void retry(Long id);
/**
* Sends everything that is due. Called by the queue job.
*
* @return how many notices were sent
*/
int processDue();
}
@@ -0,0 +1,249 @@
/**
* 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.client.service;
import java.sql.Timestamp;
import java.time.LocalDateTime;
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.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.KycContactLogData;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class KycNoticeQueueServiceImpl implements KycNoticeQueueService {
private static final String PENDING = "PENDING";
private static final String SENDING = "SENDING";
private static final String SENT = "SENT";
private static final String FAILED = "FAILED";
private static final String CANCELLED = "CANCELLED";
/** A gateway that is down is tried again a couple of times, ten minutes apart, then given up on. */
private static final int MAX_ATTEMPTS = 3;
private static final int RETRY_MINUTES = 10;
/** A notice stuck mid-send (the server stopped) is put back after this long. */
private static final int STUCK_MINUTES = 15;
private static final int BATCH = 100;
private static final int MAX_ITEMS = 200;
private final JdbcTemplate jdbcTemplate;
private final KycContactLogService contactLogService;
private final KycNoticeComposer noticeComposer;
private final PlatformSecurityContext context;
@Override
public EnqueueResult enqueue(final List<QueueItem> items, final String notes, final String officerName, final LocalDateTime sendAt) {
if (items == null || items.isEmpty()) {
throw validationError("items", "Choose at least one customer.");
}
if (items.size() > MAX_ITEMS) {
throw validationError("items", "Queue at most " + MAX_ITEMS + " notices at a time.");
}
final LocalDateTime now = DateUtils.getLocalDateTimeOfTenant();
final LocalDateTime when = sendAt == null || sendAt.isBefore(now) ? now : sendAt;
final String note = StringUtils.isBlank(notes) ? "Notice queued by an officer." : StringUtils.abbreviate(notes.trim(), 1000);
final AppUser user = this.context.getAuthenticatedUserIfPresent();
final Long userId = user != null ? user.getId() : null;
int queued = 0;
int skipped = 0;
int unreachable = 0;
for (final QueueItem item : items) {
final KycContactChannel channel = item.channel() == null ? null : KycContactChannel.fromString(item.channel());
if (item.clientId() == null || channel == null || !channel.isPlatformSent()) {
throw validationError("channel", "Only SMS, Email and WhatsApp can be queued.");
}
final String type = item.noticeType() == null ? "" : item.noticeType().toUpperCase();
if (!KycNoticeComposer.TYPE_KYC.equals(type) && !KycNoticeComposer.TYPE_DORMANCY.equals(type)) {
throw validationError("noticeType", "Notice type must be KYC or DORMANCY.");
}
// A customer with no mobile number (or no email, for Email) cannot be sent this. Queuing it would only write a
// "not sent" record every time, so it is left out and counted — those customers are found and reached another way.
final Map<String, Object> contact = this.jdbcTemplate.queryForList("SELECT mobile_no, email_address FROM m_client WHERE id = ?",
item.clientId()).stream().findFirst().orElse(null);
final String destination = contact == null ? null
: (String) contact.get(channel == KycContactChannel.EMAIL ? "email_address" : "mobile_no");
if (StringUtils.isBlank(destination)) {
unreachable++;
continue;
}
// The same notice already waiting is not queued twice; a double click must not message a customer twice.
final Integer waiting = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_kyc_notice_queue WHERE client_id = ? "
+ "AND channel = ? AND notice_type = ? AND status IN ('" + PENDING + "', '" + SENDING + "')", Integer.class,
item.clientId(), channel.name(), type);
if (waiting != null && waiting > 0) {
skipped++;
continue;
}
this.jdbcTemplate.update("INSERT INTO m_kyc_notice_queue (client_id, notice_type, channel, account_no, stage_label, notes, "
+ "officer_name, queued_by_user_id, status, attempts, scheduled_for, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)",
item.clientId(), type, channel.name(), StringUtils.abbreviate(item.accountNo(), 50),
StringUtils.abbreviate(item.stageLabel(), 100), note, StringUtils.abbreviate(officerName, 200), userId, PENDING,
Timestamp.valueOf(when), Timestamp.valueOf(now));
queued++;
}
return new EnqueueResult(queued, skipped, unreachable);
}
@Override
public List<Map<String, Object>> list(final String status, final int limit) {
final int rows = Math.min(Math.max(limit, 1), 500);
final StringBuilder sql = new StringBuilder("SELECT q.id, q.client_id, c.display_name, q.account_no, q.notice_type, q.channel, "
+ "q.stage_label, q.status, q.attempts, q.result_message, q.scheduled_for, q.created_at, q.processed_at "
+ "FROM m_kyc_notice_queue q JOIN m_client c ON c.id = q.client_id");
final List<Object> args = new ArrayList<>();
if (StringUtils.isNotBlank(status)) {
sql.append(" WHERE q.status = ?");
args.add(status.trim().toUpperCase());
}
sql.append(" ORDER BY q.id DESC LIMIT ").append(rows);
final List<Map<String, Object>> out = new ArrayList<>();
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql.toString(), args.toArray())) {
final Map<String, Object> item = new LinkedHashMap<>();
item.put("id", r.get("id"));
item.put("clientId", r.get("client_id"));
item.put("clientName", r.get("display_name"));
item.put("accountNo", r.get("account_no"));
item.put("noticeType", r.get("notice_type"));
item.put("channel", r.get("channel"));
item.put("stageLabel", r.get("stage_label"));
item.put("status", r.get("status"));
item.put("attempts", r.get("attempts"));
item.put("resultMessage", r.get("result_message"));
item.put("scheduledFor", iso(r.get("scheduled_for")));
item.put("createdAt", iso(r.get("created_at")));
item.put("processedAt", iso(r.get("processed_at")));
out.add(item);
}
return out;
}
@Override
public void cancel(final Long id) {
final int changed = this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, processed_at = ?, "
+ "result_message = 'Cancelled before it was sent.' WHERE id = ? AND status = ?", CANCELLED,
Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant()), id, PENDING);
if (changed == 0) {
throw validationError("id", "Only a notice that is still waiting can be cancelled.");
}
}
@Override
public void retry(final Long id) {
final Timestamp now = Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant());
final int changed = this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, attempts = 0, scheduled_for = ?, "
+ "result_message = NULL, processed_at = NULL WHERE id = ? AND status = ?", PENDING, now, id, FAILED);
if (changed == 0) {
throw validationError("id", "Only a notice that failed can be retried.");
}
}
@Override
public int processDue() {
final LocalDateTime now = DateUtils.getLocalDateTimeOfTenant();
// Anything left half-sent by a stopped server goes back in line.
this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ? WHERE status = ? AND processed_at < ?", PENDING, SENDING,
Timestamp.valueOf(now.minusMinutes(STUCK_MINUTES)));
final List<Map<String, Object>> due = this.jdbcTemplate.queryForList("SELECT id, client_id, notice_type, channel, notes, "
+ "officer_name, attempts FROM m_kyc_notice_queue WHERE status = ? AND scheduled_for <= ? ORDER BY id LIMIT " + BATCH,
PENDING, Timestamp.valueOf(now));
int sent = 0;
for (final Map<String, Object> row : due) {
final Long id = ((Number) row.get("id")).longValue();
// Claim it first, so two overlapping runs cannot both send the same notice.
final int claimed = this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, processed_at = ? WHERE id = ? AND status = ?",
SENDING, Timestamp.valueOf(now), id, PENDING);
if (claimed == 0) {
continue;
}
final int attempts = ((Number) row.get("attempts")).intValue() + 1;
try {
// The stage is read when the notice goes out. A customer who has since transacted or renewed their KYC is
// no longer at a stage that has a notice, and telling them they are would be wrong — so it is dropped, not failed.
if (this.noticeComposer.compose(((Number) row.get("client_id")).longValue(), (String) row.get("notice_type")) == null) {
finish(id, CANCELLED, attempts, "Not sent — the customer is no longer at a stage that has a notice (they may have transacted or renewed their KYC since).", null, null);
continue;
}
final KycContactLogData result = this.contactLogService.sendStageNotice(((Number) row.get("client_id")).longValue(),
(String) row.get("channel"), (String) row.get("notes"), (String) row.get("officer_name"),
(String) row.get("notice_type"));
final String delivery = result.getDeliveryStatus();
if ("SENT".equals(delivery)) {
finish(id, SENT, attempts, result.getDeliveryMessage(), result.getId(), null);
sent++;
} else if ("FAILED".equals(delivery) && attempts < MAX_ATTEMPTS) {
// The gateway refused it; try again shortly rather than give up on the first miss.
finish(id, PENDING, attempts, result.getDeliveryMessage(), result.getId(),
Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant().plusMinutes(RETRY_MINUTES)));
} else {
finish(id, FAILED, attempts, result.getDeliveryMessage(), result.getId(), null);
}
} catch (final RuntimeException e) {
log.warn("Queued KYC notice {} could not be sent: {}", id, e.getMessage());
finish(id, FAILED, attempts, "Could not be sent: " + e.getMessage(), null, null);
}
}
if (!due.isEmpty()) {
log.info("[KYC notice queue] {} of {} due notice(s) sent", sent, due.size());
}
return sent;
}
private void finish(final Long id, final String status, final int attempts, final String message, final Long logId,
final Timestamp nextAttempt) {
final Timestamp now = Timestamp.valueOf(DateUtils.getLocalDateTimeOfTenant());
if (nextAttempt != null) {
this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, attempts = ?, result_message = ?, contact_log_id = ?, "
+ "processed_at = ?, scheduled_for = ? WHERE id = ?", status, attempts, StringUtils.abbreviate(message, 500), logId, now,
nextAttempt, id);
} else {
this.jdbcTemplate.update("UPDATE m_kyc_notice_queue SET status = ?, attempts = ?, result_message = ?, contact_log_id = ?, "
+ "processed_at = ? WHERE id = ?", status, attempts, StringUtils.abbreviate(message, 500), logId, now, id);
}
}
private static String iso(final Object value) {
return value == null ? null : String.valueOf(value).replace(' ', 'T');
}
private PlatformApiDataValidationException validationError(final String parameter, final String message) {
final ApiParameterError error = ApiParameterError.parameterError("validation.msg.kyc.notice.queue." + parameter + ".invalid",
message, parameter);
return new PlatformApiDataValidationException(List.of(error));
}
}
@@ -0,0 +1,39 @@
/**
* 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.client.service;
/**
* Sends customers the reminder that matches the stage they have reached, and says what happens to
* their account next.
*/
public interface KycReminderService {
/**
* One pass over every customer whose re-KYC is within 30 days of falling due or already lapsed,
* and every customer with an inactive or dormant savings account.
*
* <p>
* Each customer gets the latest stage notice they have not yet been sent, by SMS and by email, and
* a notice is never repeated for the same stage. Running the pass twice in a day, or after a
* missed night, sends nothing extra.
*
* @return how many notices were attempted
*/
int sendReminders();
}
@@ -0,0 +1,184 @@
/**
* 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.client.service;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.EnumSet;
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.service.DateUtils;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycContactLogRepository;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.apache.fineract.portfolio.client.domain.ClientKycStatus;
import org.apache.fineract.portfolio.client.domain.KycContactChannel;
import org.apache.fineract.portfolio.client.domain.KycContactDeliveryStatus;
import org.apache.fineract.portfolio.savings.data.DormantAccountData;
import org.apache.fineract.portfolio.savings.service.DormantAccountReadPlatformService;
import org.springframework.stereotype.Service;
/**
* The nightly reminder pass.
*
* <p>
* The restriction job ({@link KycExpiryRestrictionService}) acts on accounts; this one tells the
* customer first, and tells them what comes next. A customer is therefore never surprised by a
* freeze they were not warned of.
*
* <p>
* The stage a customer is at is worked out from today's date every run rather than remembered, and
* the latest stage they have reached is the one they are told about. A missed night therefore costs
* nothing: the next run sends the current stage, not the stale one.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KycReminderServiceImpl implements KycReminderService {
/** Days from expiry (negative = before) at which a customer's KYC stage changes. */
/**
* Days from expiry (negative = before) at which a KYC reminder goes out: 30, 15, 7 and 1 day before it expires, the
* day it expires, then 7 and 1 day before debits stop (day 90) and before the account goes inoperative (day 180), and
* on each of those two days themselves.
*/
private static final int[] KYC_MILESTONES = { -30, -15, -7, -1, 0, 83, 89, 90, 173, 179, 180 };
/** An account is warned 7 days and 1 day before it moves to its next stage. */
private static final long WARN_DAYS = 7;
private static final int SUB_STATUS_INACTIVE = 100;
private static final int SUB_STATUS_DORMANT = 200;
/** A failing gateway is retried on later nights, but not for ever. */
private static final long MAX_ATTEMPTS = 3;
private static final String OFFICER = "System — automatic reminder";
private static final List<KycContactChannel> CHANNELS = List.of(KycContactChannel.SMS, KycContactChannel.EMAIL);
private final ClientKycRepository kycRepository;
private final ClientKycContactLogRepository contactLogRepository;
private final KycContactLogService contactLogService;
private final DormantAccountReadPlatformService dormantAccountReadPlatformService;
@Override
public int sendReminders() {
final LocalDate today = DateUtils.getBusinessLocalDate();
int attempted = 0;
final List<ClientKyc> records = new ArrayList<>(
kycRepository.findExpiringBetween(today, today.plusDays(-KYC_MILESTONES[0]), ClientKycStatus.REJECTED.getValue()));
records.addAll(kycRepository.findLapsedAsOn(today, ClientKycStatus.REJECTED.getValue()));
for (final ClientKyc kyc : records) {
try {
attempted += remindKyc(kyc, today);
} catch (final RuntimeException e) {
// One customer's failure must not stop the rest of the night's reminders.
log.warn("KYC reminder for client {} failed: {}", kyc.getClient() != null ? kyc.getClient().getId() : null,
e.getMessage());
}
}
attempted += remindDormant(today);
log.info("[KYC reminders] {} notice(s) attempted", attempted);
return attempted;
}
// ── Re-KYC ─────────────────────────────────────────────────────────────────
private int remindKyc(final ClientKyc kyc, final LocalDate today) {
if (kyc.getClient() == null || kyc.getExpiryDate() == null) {
return 0;
}
final long offset = ChronoUnit.DAYS.between(kyc.getExpiryDate(), today);
Integer milestone = null;
for (final int m : KYC_MILESTONES) {
if (offset >= m) {
milestone = m;
}
}
if (milestone == null) {
return 0;
}
// The expiry date is in the marker so a renewed KYC starts a fresh set of reminders.
final String marker = "[AUTO KYC " + kyc.getExpiryDate() + " M" + milestone + "]";
return sendBoth(kyc.getClient().getId(), marker, KycNoticeComposer.kycSubject(offset),
KycNoticeComposer.kycMessage(kyc.getExpiryDate(), offset));
}
// ── Inactive / dormant accounts ────────────────────────────────────────────
private int remindDormant(final LocalDate today) {
// One notice per customer, about the account that has been quiet longest.
final Map<Long, DormantAccountData> worst = new LinkedHashMap<>();
for (final DormantAccountData account : dormantAccountReadPlatformService.retrieveDormantAccounts(null, null)) {
final Integer sub = account.getSubStatus();
if (account.getClientId() == null || sub == null || (sub != SUB_STATUS_INACTIVE && sub != SUB_STATUS_DORMANT)) {
continue; // escheated accounts are closed; there is nothing left to warn about
}
final DormantAccountData held = worst.get(account.getClientId());
if (held == null || idle(account) > idle(held)) {
worst.put(account.getClientId(), account);
}
}
int attempted = 0;
for (final DormantAccountData account : worst.values()) {
try {
// The step is "E" (the account has just entered this stage), "D7" (a week before the next) or "D1" (the day
// before). The latest one reached is what the customer is told, once.
final Long remaining = account.getSubStatus() == SUB_STATUS_DORMANT ? account.getDaysToEscheat() : account.getDaysToDormancy();
final String step = remaining != null && remaining <= 1 ? "D1" : remaining != null && remaining <= WARN_DAYS ? "D7" : "E";
final String marker = "[AUTO DORMANCY S" + account.getSubStatus() + " " + account.getLastCustomerTransactionDate() + " " + step + "]";
final String subject = KycNoticeComposer.dormantSubject(account);
attempted += sendBoth(account.getClientId(), marker, subject, KycNoticeComposer.dormantMessage(account, today));
} catch (final RuntimeException e) {
log.warn("Dormancy reminder for client {} failed: {}", account.getClientId(), e.getMessage());
}
}
return attempted;
}
private static long idle(final DormantAccountData account) {
return account.getDaysIdle() == null ? 0 : account.getDaysIdle();
}
// ── Sending ────────────────────────────────────────────────────────────────
/** Sends the message by every channel, skipping any channel this stage has already been told on. */
private int sendBoth(final Long clientId, final String marker, final String subject, final String body) {
int attempted = 0;
for (final KycContactChannel channel : CHANNELS) {
// Delivered, or nothing to deliver to, is final. A failure is retried, a few times only.
final boolean settled = contactLogRepository.existsByClientIdAndChannelAndNotesStartingWithAndDeliveryStatusIn(clientId,
channel, marker, EnumSet.of(KycContactDeliveryStatus.SENT, KycContactDeliveryStatus.SKIPPED_NO_DESTINATION));
if (settled || contactLogRepository.countByClientIdAndChannelAndNotesStartingWith(clientId, channel, marker) >= MAX_ATTEMPTS) {
continue;
}
contactLogService.sendNotice(clientId, channel.name(), marker + " Automatic reminder by " + channel.getDisplayLabel() + ".",
OFFICER, subject, body);
attempted++;
}
return attempted;
}
}
@@ -92,7 +92,8 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
txnRule != null ? txnRule.getThresholdAmountMediumRisk() : null, txnRule != null ? txnRule.getThresholdAmountMediumRisk() : null,
txnRule != null ? txnRule.getThresholdAmountHighRisk() : null, txnRule != null ? txnRule.getThresholdAmountHighRisk() : null,
txnRule != null ? txnRule.getMinLimitAmount() : null, txnRule != null ? txnRule.getMinLimitAmount() : null,
txnRule != null ? txnRule.getMaxLimitAmount() : null txnRule != null ? txnRule.getMaxLimitAmount() : null,
rule.isCcRelevant()
); );
} }
} }
@@ -64,7 +64,9 @@ public class ClientSearchService {
Pageable pageable = searchRequest.toPageable(); Pageable pageable = searchRequest.toPageable();
return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly, legalFormValue) Long officeId = request.map(ClientTextSearch::getOfficeId).orElse(null);
return clientRepository.searchByText(searchText, pageable, hierarchy, activeOnly, legalFormValue, officeId)
.map(clientSearchDataMapper::map); .map(clientSearchDataMapper::map);
} }
@@ -41,4 +41,11 @@ public class ClientTextSearch {
* not be able to link entities by omitting the param). Defaults to null = all legal forms. * not be able to link entities by omitting the param). Defaults to null = all legal forms.
*/ */
private String legalFormType; private String legalFormType;
/**
* Narrows results to one branch (office). It is applied on top of the caller's office-hierarchy
* restriction, never instead of it, so a branch user passing another branch's id still gets nothing.
* Used by the client list's branch filter for Head Office users. Null = no extra filter.
*/
private Long officeId;
} }
@@ -53,14 +53,16 @@ public class CustomerSearchApiResource {
@GET @GET
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Search for a customer", description = "AND-combines whichever of name/accountNumber/aadhaarNumber/panNumber/address/accountTypes are supplied - at least one is required. accountTypes is a comma-separated list (SAVINGS,CURRENT,FIXED_DEPOSIT,RECURRING_DEPOSIT).") @Operation(summary = "Search for a customer", description = "AND-combines whichever of clientId/name/mobileNo/accountNumber/aadhaarNumber/panNumber/address/accountTypes are supplied - at least one is required. accountTypes is a comma-separated list (SAVINGS,CURRENT,FIXED_DEPOSIT,RECURRING_DEPOSIT).")
public Collection<CustomerSearchResultData> search(@QueryParam("name") final String name, public Collection<CustomerSearchResultData> search(@QueryParam("clientId") final String clientId,
@QueryParam("name") final String name, @QueryParam("mobileNo") final String mobileNo,
@QueryParam("accountNumber") final String accountNumber, @QueryParam("aadhaarNumber") final String aadhaarNumber, @QueryParam("accountNumber") final String accountNumber, @QueryParam("aadhaarNumber") final String aadhaarNumber,
@QueryParam("panNumber") final String panNumber, @QueryParam("address") final String address, @QueryParam("panNumber") final String panNumber, @QueryParam("address") final String address,
@QueryParam("accountTypes") final String accountTypes) { @QueryParam("accountTypes") final String accountTypes) {
final List<String> accountTypeList = accountTypes == null || accountTypes.isBlank() ? List.of() final List<String> accountTypeList = accountTypes == null || accountTypes.isBlank() ? List.of()
: Arrays.asList(accountTypes.split(",")); : Arrays.asList(accountTypes.split(","));
return safely(() -> this.readPlatformService.search(name, accountNumber, aadhaarNumber, panNumber, address, accountTypeList)); return safely(() -> this.readPlatformService.search(clientId, name, mobileNo, accountNumber, aadhaarNumber, panNumber, address,
accountTypeList));
} }
/** Surfaces IllegalArgumentException as a clean 400 instead of an opaque 500. */ /** Surfaces IllegalArgumentException as a clean 400 instead of an opaque 500. */
@@ -27,12 +27,13 @@ public interface CustomerSearchReadPlatformService {
/** /**
* Multi-field AND search - only non-blank parameters are applied. At least one must be * Multi-field AND search - only non-blank parameters are applied. At least one must be
* supplied. name/address are partial, case-insensitive matches; accountNumber/aadhaarNumber/ * supplied. name/address are partial, case-insensitive matches; accountNumber/aadhaarNumber/
* panNumber are exact matches (they're precise identifiers, not free text). accountTypes, if * panNumber are exact matches (they're precise identifiers, not free text). clientId is an exact match
* on the client's id and mobileNo a partial match on their mobile number. accountTypes, if
* non-empty, restricts results to those account types only (SAVINGS/CURRENT/FIXED_DEPOSIT/ * non-empty, restricts results to those account types only (SAVINGS/CURRENT/FIXED_DEPOSIT/
* RECURRING_DEPOSIT) - a client with no account, or only accounts of other types, is excluded * RECURRING_DEPOSIT) - a client with no account, or only accounts of other types, is excluded
* entirely when this filter is active (unlike the unfiltered case, where a client with no * entirely when this filter is active (unlike the unfiltered case, where a client with no
* account at all still appears once for informational purposes). * account at all still appears once for informational purposes).
*/ */
Collection<CustomerSearchResultData> search(String name, String accountNumber, String aadhaarNumber, String panNumber, Collection<CustomerSearchResultData> search(String clientId, String name, String mobileNo, String accountNumber, String aadhaarNumber,
String address, List<String> accountTypes); String panNumber, String address, List<String> accountTypes);
} }
@@ -69,8 +69,11 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
"""; """;
@Override @Override
public Collection<CustomerSearchResultData> search(final String name, final String accountNumber, final String aadhaarNumber, public Collection<CustomerSearchResultData> search(final String clientId, final String name, final String mobileNo,
final String panNumber, final String address, final List<String> accountTypes) { final String accountNumber, final String aadhaarNumber, final String panNumber, final String address,
final List<String> accountTypes) {
final boolean hasClientId = clientId != null && !clientId.isBlank();
final boolean hasMobile = mobileNo != null && !mobileNo.isBlank();
final boolean hasName = name != null && !name.isBlank(); final boolean hasName = name != null && !name.isBlank();
final boolean hasAccountNumber = accountNumber != null && !accountNumber.isBlank(); final boolean hasAccountNumber = accountNumber != null && !accountNumber.isBlank();
final boolean hasAadhaar = aadhaarNumber != null && !aadhaarNumber.isBlank(); final boolean hasAadhaar = aadhaarNumber != null && !aadhaarNumber.isBlank();
@@ -79,9 +82,12 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
final List<String> effectiveAccountTypes = accountTypes == null ? List.of() final List<String> effectiveAccountTypes = accountTypes == null ? List.of()
: accountTypes.stream().filter(t -> t != null && !t.isBlank()).map(String::trim).map(String::toUpperCase).toList(); : accountTypes.stream().filter(t -> t != null && !t.isBlank()).map(String::trim).map(String::toUpperCase).toList();
if (!hasName && !hasAccountNumber && !hasAadhaar && !hasPan && !hasAddress && effectiveAccountTypes.isEmpty()) { if (!hasClientId && !hasMobile && !hasName && !hasAccountNumber && !hasAadhaar && !hasPan && !hasAddress
throw new IllegalArgumentException("Enter at least one search field - name, account number, Aadhaar, PAN, or address."); && effectiveAccountTypes.isEmpty()) {
throw new IllegalArgumentException(
"Enter at least one search field - client ID, name, mobile number, account number, Aadhaar, PAN, or address.");
} }
final Long clientIdValue = hasClientId ? parseClientId(clientId) : null;
for (final String type : effectiveAccountTypes) { for (final String type : effectiveAccountTypes) {
if (!VALID_ACCOUNT_TYPES.contains(type)) { if (!VALID_ACCOUNT_TYPES.contains(type)) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
@@ -92,10 +98,19 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
final StringBuilder sql = new StringBuilder(BASE_SELECT).append(" WHERE 1=1 "); final StringBuilder sql = new StringBuilder(BASE_SELECT).append(" WHERE 1=1 ");
final List<Object> params = new ArrayList<>(); final List<Object> params = new ArrayList<>();
if (hasClientId) {
sql.append(" AND c.id = ? ");
params.add(clientIdValue);
}
if (hasName) { if (hasName) {
sql.append(" AND LOWER(c.display_name) LIKE LOWER(?) "); sql.append(" AND LOWER(c.display_name) LIKE LOWER(?) ");
params.add("%" + name.trim() + "%"); params.add("%" + name.trim() + "%");
} }
if (hasMobile) {
// Partial, so the last digits of a number find it as well as the whole of it.
sql.append(" AND c.mobile_no LIKE ? ");
params.add("%" + mobileNo.trim() + "%");
}
if (hasAccountNumber) { if (hasAccountNumber) {
sql.append(" AND acct.account_no = ? "); sql.append(" AND acct.account_no = ? ");
params.add(accountNumber.trim()); params.add(accountNumber.trim());
@@ -131,6 +146,15 @@ public class CustomerSearchReadPlatformServiceImpl implements CustomerSearchRead
return this.jdbcTemplate.query(sql.toString(), new CustomerSearchResultMapper(), params.toArray()); return this.jdbcTemplate.query(sql.toString(), new CustomerSearchResultMapper(), params.toArray());
} }
/** A client id is a whole number; anything else is the caller's mistake to be told about, not a server error. */
private static Long parseClientId(final String clientId) {
try {
return Long.valueOf(clientId.trim());
} catch (final NumberFormatException e) {
throw new IllegalArgumentException("Client ID must be a number.");
}
}
private static final class CustomerSearchResultMapper implements RowMapper<CustomerSearchResultData> { private static final class CustomerSearchResultMapper implements RowMapper<CustomerSearchResultData> {
@Override @Override
public CustomerSearchResultData mapRow(final ResultSet rs, final int rowNum) throws SQLException { public CustomerSearchResultData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
@@ -19,9 +19,13 @@
package org.apache.fineract.portfolio.dds.accounting; package org.apache.fineract.portfolio.dds.accounting;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.domain.GLAccount; import org.apache.fineract.accounting.glaccount.domain.GLAccount;
@@ -83,35 +87,16 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor @RequiredArgsConstructor
public class DdsLedger { public class DdsLedger {
/** Cash an agent has collected but not yet handed to a branch. Set by 0493. */
public static final String AGENT_CASH = "dds-agent-cash-account";
/** What an agent's commission costs the bank. Set by 0491. */
public static final String COMMISSION_EXPENSE = "dds-agent-commission-expense-account";
/** The savings product DDS opens customer accounts under. Set by 0492. */
public static final String DEPOSIT_PRODUCT = "dds-deposit-product";
/** What DDS customers are owed on accounts that are DDS's own rather than savings accounts. Set by 0516. */
public static final String DEPOSIT_LIABILITY = "dds-deposit-liability-account";
/** What interest on DDS's own accounts costs the bank. Set by 0518. */
public static final String INTEREST_EXPENSE = "dds-interest-expense-account";
private static final String ORGANISATION_CURRENCY_SQL = "select code from m_organisation_currency order by id limit 1"; private static final String ORGANISATION_CURRENCY_SQL = "select code from m_organisation_currency order by id limit 1";
private static final String PRODUCT_CURRENCY_SQL = "select currency_code from m_savings_product where id = ?";
private static final String CONFIGURED_ACCOUNT_SQL = "select value from c_configuration where name = ? and enabled = true";
/** /**
* The accounts money can be parked in for a customer with no account here. Every bank names its * The accounts money can be parked in for a customer with no account here. Every bank names its
* Sundry account differently, so none is assumed: every open posting liability account is offered * Sundry account differently, so none is assumed: every open posting liability account is offered
* and the clerk searches it by code or name and picks their bank's own. Liability, because the bank * and the clerk searches it by code or name and picks their bank's own. Liability, because the bank
* now owes the money - an income or expense account would book it as the bank's own. Posting, * now owes the money - an income or expense account would book it as the bank's own. Posting,
* because a header cannot take an entry. And never an account whose balance is meant to equal the * because a header cannot take an entry. And never an account whose balance is meant to equal the
* sum of customer or agent accounts - DDS Customer Deposits, a savings or deposit product's control * sum of customer or agent accounts - a DDS product's customer deposits account, a savings or
* account, a current-account control, an agent's own cash account - since parked money in any of * deposit product's control account, an agent's own cash account - since parked money in any of
* those would leave it no longer adding up. * those would leave it no longer adding up.
*/ */
private static final String SUNDRY_ACCOUNTS_SQL = "select g.id as \"id\", g.gl_code as \"glCode\", g.name as \"name\" " private static final String SUNDRY_ACCOUNTS_SQL = "select g.id as \"id\", g.gl_code as \"glCode\", g.name as \"name\" "
@@ -120,8 +105,7 @@ public class DdsLedger {
+ "and g.id not in (select m.gl_account_id from acc_product_mapping m where m.product_type = 2 " + "and g.id not in (select m.gl_account_id from acc_product_mapping m where m.product_type = 2 "
+ "and m.financial_account_type = 2 and m.gl_account_id is not null) " + "and m.financial_account_type = 2 and m.gl_account_id is not null) "
+ "and g.id not in (select a.gl_account_id from m_dds_agent a where a.gl_account_id is not null) " + "and g.id not in (select a.gl_account_id from m_dds_agent a where a.gl_account_id is not null) "
+ "and g.id not in (select c.value from c_configuration c where c.name in ('dds-deposit-liability-account', " + "and g.id not in (select t.deposit_liability_gl_id from m_dds_account_type t where t.deposit_liability_gl_id is not null) "
+ "'dds-current-account-control-account', 'dds-agent-cash-account') and c.value is not null) "
+ "order by g.gl_code"; + "order by g.gl_code";
/** /**
@@ -164,7 +148,7 @@ public class DdsLedger {
* Deposits, so a transfer between them writes no leg, yet the slip still has to name both. * Deposits, so a transfer between them writes no leg, yet the slip still has to name both.
*/ */
public void voucherSideOnDeposits(final String entryType, final Party party, final BigDecimal amount, final String description) { public void voucherSideOnDeposits(final String entryType, final Party party, final BigDecimal amount, final String description) {
DdsVoucherScope.addLine(new DdsVoucherScope.Line(entryType, configured(DEPOSIT_LIABILITY).getId(), DdsVoucherScope.addLine(new DdsVoucherScope.Line(entryType, depositLiabilityOf(party).getId(),
party.getPartyCode() + " · " + party.getPartyName(), party.getAccountNo(), amount, description)); party.getPartyCode() + " · " + party.getPartyName(), party.getAccountNo(), amount, description));
} }
@@ -183,17 +167,6 @@ public class DdsLedger {
return deposit; return deposit;
} }
/**
* Moves cash from an agent's hands into a branch. No customer account is involved - the customers
* were credited when their money was collected; this is the bank catching up with the cash.
*/
public void settle(final Office office, final LocalDate date, final BigDecimal amount, final String description,
final GLAccount agentCashAccount) {
final Long productId = depositProductId();
postPair(office, currencyOf(productId), date, amount, productAccount(productId, FUND_SOURCE), agentCashOr(agentCashAccount),
description, null, null);
}
/** /**
* Hands the customers of one application their money from the cashier's DDS receipts, in one entry. * Hands the customers of one application their money from the cashier's DDS receipts, in one entry.
* *
@@ -207,11 +180,13 @@ public class DdsLedger {
* DR the account the receipts credited the agent's account clears * DR the account the receipts credited the agent's account clears
* CR DDS Customer Deposits what the bank now owes the customers * CR DDS Customer Deposits what the bank now owes the customers
* </pre> * </pre>
*
* One pair per customer-deposits account rather than one in all: each DDS product names its own
* (see depositLiabilityOf), so customers on two products are owed on two accounts.
*/ */
public void applyAgentReceipts(final Office office, final LocalDate date, final BigDecimal amount, final GLAccount receiptAccount, public void applyAgentReceipts(final Office office, final LocalDate date, final BigDecimal amount, final GLAccount receiptAccount,
final String transactionId, final String description) { final GLAccount depositAccount, final String transactionId, final String description) {
writePair(office, organisationCurrency(), transactionId, date, amount, receiptAccount, configured(DEPOSIT_LIABILITY), description, writePair(office, organisationCurrency(), transactionId, date, amount, receiptAccount, depositAccount, description, null, null);
null, null);
} }
/** /**
@@ -242,7 +217,7 @@ public class DdsLedger {
collect(party.getSavingsAccountId(), date, amount, description, agentCashAccount); collect(party.getSavingsAccountId(), date, amount, description, agentCashAccount);
return; return;
} }
ddsPair(party, date, amount, agentCashOr(agentCashAccount), configured(DEPOSIT_LIABILITY), description); ddsPair(party, date, amount, agentCashOr(agentCashAccount), depositLiabilityOf(party), description);
} }
/** Interest onto the party's account: Dr interest expense, Cr what the customer is owed. */ /** Interest onto the party's account: Dr interest expense, Cr what the customer is owed. */
@@ -251,7 +226,7 @@ public class DdsLedger {
postInterest(party.getSavingsAccountId(), date, amount, description); postInterest(party.getSavingsAccountId(), date, amount, description);
return; return;
} }
ddsPair(party, date, amount, configured(INTEREST_EXPENSE), configured(DEPOSIT_LIABILITY), description); ddsPair(party, date, amount, interestExpenseOf(party), depositLiabilityOf(party), description);
} }
/** Interest taken back on an early closure - the exact opposite of {@link #postInterest(Party, LocalDate, BigDecimal, String)}. */ /** Interest taken back on an early closure - the exact opposite of {@link #postInterest(Party, LocalDate, BigDecimal, String)}. */
@@ -260,7 +235,7 @@ public class DdsLedger {
reverseInterest(party.getSavingsAccountId(), date, amount, description); reverseInterest(party.getSavingsAccountId(), date, amount, description);
return; return;
} }
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), configured(INTEREST_EXPENSE), description); ddsPair(party, date, amount, depositLiabilityOf(party), interestExpenseOf(party), description);
} }
/** /**
@@ -275,7 +250,7 @@ public class DdsLedger {
} }
final SavingsAccount to = load(toSavingsAccountId); final SavingsAccount to = load(toSavingsAccountId);
final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount); final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount);
postPair(to, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(to), description); postPair(to, deposit, date, amount, depositLiabilityOf(party), controlAccountOf(to), description);
} }
/** /**
@@ -295,24 +270,23 @@ public class DdsLedger {
final String description) { final String description) {
final GLAccount owed; final GLAccount owed;
final Office office; final Office office;
Long withdrawalId = null;
Long savingsAccountId = null;
if (party.getSavingsAccountId() != null) { if (party.getSavingsAccountId() != null) {
final SavingsAccount account = load(party.getSavingsAccountId()); final SavingsAccount account = load(party.getSavingsAccountId());
final boolean wasBlocked = unlockDebitsIfBlocked(account); withdrawalId = withdraw(account, date, amount).getId();
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true); savingsAccountId = account.getId();
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
owed = controlAccountOf(account); owed = controlAccountOf(account);
office = account.office(); office = account.office();
} else { } else {
owed = configured(DEPOSIT_LIABILITY); owed = depositLiabilityOf(party);
office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice(); office = officeOf(party);
} }
creditCurrentAccount(currentAccountId, date, amount, description); creditCurrentAccount(currentAccountId, date, amount, description);
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8); final String transactionId = withdrawalId != null ? "S" + withdrawalId : partyLegId(party);
writePair(office, organisationCurrency(), transactionId, date, amount, owed, currentAccountControl(currentAccountId), description, writePair(office, organisationCurrency(), transactionId, date, amount, owed, currentAccountControl(currentAccountId), description,
null, null); withdrawalId, savingsAccountId);
} }
private static final String CURRENT_ACCOUNT_SQL = "select status_enum, account_balance_derived, office_id from m_current_account where id = ?"; private static final String CURRENT_ACCOUNT_SQL = "select status_enum, account_balance_derived, office_id from m_current_account where id = ?";
@@ -329,9 +303,6 @@ public class DdsLedger {
+ " join acc_product_mapping apm on apm.product_id = ca.product_id " + " join acc_product_mapping apm on apm.product_id = ca.product_id "
+ " where ca.id = ? and apm.product_type = 2 and apm.financial_account_type = 2 and apm.payment_type is null"; + " where ca.id = ? and apm.product_type = 2 and apm.financial_account_type = 2 and apm.payment_type is null";
private static final String CURRENT_CONTROL_OVERRIDE_SQL = "select value from c_configuration "
+ " where name = 'dds-current-account-control-account' and enabled = true";
/** A deposit row and the balances on the current account, refused unless it is active. */ /** A deposit row and the balances on the current account, refused unless it is active. */
private void creditCurrentAccount(final Long currentAccountId, final LocalDate date, final BigDecimal amount, final String description) { private void creditCurrentAccount(final Long currentAccountId, final LocalDate date, final BigDecimal amount, final String description) {
final List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(CURRENT_ACCOUNT_SQL, currentAccountId); // NOSONAR final List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(CURRENT_ACCOUNT_SQL, currentAccountId); // NOSONAR
@@ -348,23 +319,16 @@ public class DdsLedger {
this.jdbcTemplate.update(CURRENT_BALANCE_SQL, amount, amount, currentAccountId); // NOSONAR this.jdbcTemplate.update(CURRENT_BALANCE_SQL, amount, amount, currentAccountId); // NOSONAR
} }
/** /** The current account's control account, from the current product's own accounting. */
* The current account's control account: its product's mapping when that is the only one, and the
* setting when the product shares an id - and so its mapping rows - with a savings product.
*/
private GLAccount currentAccountControl(final Long currentAccountId) { private GLAccount currentAccountControl(final Long currentAccountId) {
final List<Long> ids = this.jdbcTemplate.queryForList(CURRENT_CONTROL_SQL, Long.class, currentAccountId); // NOSONAR final List<Long> ids = this.jdbcTemplate.queryForList(CURRENT_CONTROL_SQL, Long.class, currentAccountId); // NOSONAR
if (ids.size() == 1) { if (ids.size() == 1) {
return this.glAccountRepository.findById(ids.get(0)) return this.glAccountRepository.findById(ids.get(0))
.orElseThrow(() -> new DdsLedgerNotConfiguredException("control account for current account " + currentAccountId)); .orElseThrow(() -> new DdsLedgerNotConfiguredException("control account for current account " + currentAccountId));
} }
final List<Long> override = this.jdbcTemplate.queryForList(CURRENT_CONTROL_OVERRIDE_SQL, Long.class); // NOSONAR
if (!override.isEmpty() && override.get(0) != null) {
return this.glAccountRepository.findById(override.get(0))
.orElseThrow(() -> new DdsLedgerNotConfiguredException("dds-current-account-control-account"));
}
throw new DdsLedgerNotConfiguredException(ids.isEmpty() ? "control account for current account " + currentAccountId throw new DdsLedgerNotConfiguredException(ids.isEmpty() ? "control account for current account " + currentAccountId
: "dds-current-account-control-account (the current product's GL mapping is shared with a savings product of the same id)"); : "a single control account for current account " + currentAccountId
+ " (its current product's GL mapping is shared with a savings product of the same id)");
} }
/** The party's balance parked in the Sundry account chosen for it - see sundryAccount. */ /** The party's balance parked in the Sundry account chosen for it - see sundryAccount. */
@@ -374,7 +338,7 @@ public class DdsLedger {
payOutToPayable(party.getSavingsAccountId(), date, amount, description, parkIn); payOutToPayable(party.getSavingsAccountId(), date, amount, description, parkIn);
return; return;
} }
ddsPair(party, date, amount, configured(DEPOSIT_LIABILITY), parkIn, description); ddsPair(party, date, amount, depositLiabilityOf(party), parkIn, description);
} }
/** The accounts a customer's balance may be parked in, by GL code. None is chosen for the clerk. */ /** The accounts a customer's balance may be parked in, by GL code. None is chosen for the clerk. */
@@ -417,44 +381,169 @@ public class DdsLedger {
transfer(from.getSavingsAccountId(), to.getSavingsAccountId(), date, amount, description); transfer(from.getSavingsAccountId(), to.getSavingsAccountId(), date, amount, description);
} else if (fromSavings) { } else if (fromSavings) {
final SavingsAccount account = load(from.getSavingsAccountId()); final SavingsAccount account = load(from.getSavingsAccountId());
final boolean wasBlocked = unlockDebitsIfBlocked(account); final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
configured(DEPOSIT_LIABILITY), description, null, account.getId()); depositLiabilityOf(from), description, withdrawal.getId(), account.getId());
} else if (toSavings) { } else if (toSavings) {
final SavingsAccount account = load(to.getSavingsAccountId()); final SavingsAccount account = load(to.getSavingsAccountId());
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount); final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
postPair(account, deposit, date, amount, configured(DEPOSIT_LIABILITY), controlAccountOf(account), description); postPair(account, deposit, date, amount, depositLiabilityOf(to), controlAccountOf(account), description);
} else { } else {
// Both on DDS Customer Deposits: nothing to book, but the slip still names who paid whom. final GLAccount fromDeposits = depositLiabilityOf(from);
final GLAccount toDeposits = depositLiabilityOf(to);
if (fromDeposits.getId().equals(toDeposits.getId())) {
// Both owed on the same account: nothing to book, but the slip still names who paid whom -
// and the two passbook lines claim no journal, since none was written for them.
DdsVoucherScope.forgetLastLedgerTransaction();
voucherSideOnDeposits("DR", from, amount, description); voucherSideOnDeposits("DR", from, amount, description);
voucherSideOnDeposits("CR", to, amount, description); voucherSideOnDeposits("CR", to, amount, description);
} else {
// Two products with customer-deposit accounts of their own: the debt moves from one to the other.
ddsPair(from, date, amount, fromDeposits, toDeposits, description);
}
} }
} }
/** One pair for a party with no savings account: at the party's own branch, in the bank's currency. */ /** One pair for a party with no savings account: at the party's own branch, in the bank's currency. */
private void ddsPair(final Party party, final LocalDate date, final BigDecimal amount, final GLAccount debit, final GLAccount credit, private void ddsPair(final Party party, final LocalDate date, final BigDecimal amount, final GLAccount debit, final GLAccount credit,
final String description) { final String description) {
final Office office = party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice(); writePair(officeOf(party), organisationCurrency(), partyLegId(party), date, amount, debit, credit, description, null, null);
final String transactionId = "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().substring(0, 8);
writePair(office, organisationCurrency(), transactionId, date, amount, debit, credit, description, null, null);
} }
/** /**
* Which account plays the agent's part on an entry: the agent's own, or the scheme's shared one * The account playing the agent's part on an entry - the agent's own. An agent cannot be saved
* when they have none. * without one, so a missing one here is an agent set up before that was required.
*
* Only the account differs. Both sides of the entry, their direction and the amount are the same
* either way - an agent with their own account is not accounted for differently, only separately.
* That is what makes the shared account a safe fallback rather than a second behaviour: an agent
* set up before this existed posts exactly as they always did, and a branch can move agents onto
* their own accounts one at a time.
*/ */
private GLAccount agentCashOr(final GLAccount agentCashAccount) { private GLAccount agentCashOr(final GLAccount agentCashAccount) {
return agentCashAccount != null ? agentCashAccount : configured(AGENT_CASH); if (agentCashAccount == null) {
throw new DdsLedgerNotConfiguredException("the agent's GL account (Organization > Agent Master)");
}
return agentCashAccount;
}
// ---------------------------------------------------------------------------------------------
// A party's product's own ledger accounts.
//
// Each DDS product names the accounts it posts through on its Accounting tab (0620), as a savings
// product does, and those are the only accounts DDS posts through - there is no bank-wide account
// behind them. A product that has not named one stops the posting with a message saying which
// account to set on which product, rather than the money landing somewhere nobody chose.
// ---------------------------------------------------------------------------------------------
/** Columns of m_dds_account_type a product's account may be read from - never anything a caller typed. */
private static final Set<String> PRODUCT_GL_COLUMNS = Set.of("deposit_liability_gl_id", "interest_expense_gl_id",
"commission_expense_gl_id");
/** What the bank owes this party - their product's customer-deposits account. */
public GLAccount depositLiabilityOf(final Party party) {
return productAccount(party, "deposit_liability_gl_id", "DDS Customer Deposits");
}
/** What this party's interest costs the bank - their product's interest-expense account. */
public GLAccount interestExpenseOf(final Party party) {
return productAccount(party, "interest_expense_gl_id", "Interest Expense");
}
private GLAccount productAccount(final Party party, final String column, final String label) {
if (!PRODUCT_GL_COLUMNS.contains(column)) {
throw new IllegalArgumentException(column);
}
final String productCode = party == null || party.getSubType() == null ? null : party.getSubType().trim().toUpperCase();
if (productCode == null) {
throw new DdsLedgerNotConfiguredException("a DDS product for party " + (party == null ? "?" : party.getPartyCode()));
}
// By the id the account is tied to; by its code only for an account saved before ids were kept.
final List<Long> ids = party.getAccountTypeId() != null
? this.jdbcTemplate.queryForList("select " + column + " from m_dds_account_type where id = ?", Long.class, // NOSONAR
party.getAccountTypeId())
: this.jdbcTemplate.queryForList("select " + column + " from m_dds_account_type where account_type_code = ?", // NOSONAR
Long.class, productCode);
if (ids.isEmpty()) {
throw new DdsLedgerNotConfiguredException("DDS product " + productCode + " (party " + party.getPartyCode() + ")");
}
return accountNamed(ids.get(0), productCode, label);
}
private GLAccount accountNamed(final Long glAccountId, final String productCode, final String label) {
final Optional<GLAccount> account = glAccountId == null ? Optional.empty() : this.glAccountRepository.findById(glAccountId);
return account.orElseThrow(() -> new DdsLedgerNotConfiguredException(
"the " + label + " account on DDS product " + productCode + " (Products > DDS Products > Edit > Accounting)"));
}
// ---------------------------------------------------------------------------------------------
// An agent's commission, by product.
//
// Commission is paid to an agent in one sum for a round that may collect for several products, and
// each product books its share on its own commission account. The share is each product's part of
// the collections the commission was earned on: those a commission run has counted and no payment
// has yet covered. Paying marks them, so the next payment is shared out on the next collections.
// ---------------------------------------------------------------------------------------------
private static final String COMMISSION_BASIS_SQL = "select upper(trim(p.sub_type)) as \"product\", t.commission_expense_gl_id as \"glId\", "
+ "sum(pt.amount) as \"volume\" from m_dds_party_transaction pt join m_dds_party p on p.id = pt.party_id "
+ "left join m_dds_account_type t on t.account_type_code = upper(trim(p.sub_type)) "
+ "where pt.agent_id = ? and pt.transaction_type = 'PARTY_TRAN' and pt.direction = 'CREDIT' "
+ "and pt.commission_batch_run_id is not null and pt.commission_batch_run_id < ? %s group by upper(trim(p.sub_type)), t.commission_expense_gl_id "
+ "order by upper(trim(p.sub_type))";
private static final String UNPAID_COMMISSION_FILTER = "and pt.commission_paid_batch_run_id is null";
private static final String MARK_COMMISSION_PAID_SQL = "update m_dds_party_transaction set commission_paid_batch_run_id = ? "
+ "where agent_id = ? and transaction_type = 'PARTY_TRAN' and direction = 'CREDIT' and commission_batch_run_id is not null "
+ "and commission_batch_run_id < ? and commission_paid_batch_run_id is null";
/**
* An agent's commission split across the commission accounts of the products they collected for,
* in proportion to what they collected for each. The last share takes the rounding, so the shares
* always add up to the amount paid.
*
* Only collections counted by commission runs before the paying run are its basis: a run counted
* after this payment was generated earned commission this payment does not include. Collections
* counted before payments were tracked by run have no payment marked against them; if nothing
* unpaid is left, the agent's counted collections as a whole set the proportions instead.
*/
public Map<GLAccount, BigDecimal> commissionSplit(final Long agentId, final BigDecimal amount, final Long payingRunId) {
List<Map<String, Object>> basis = this.jdbcTemplate.queryForList(String.format(COMMISSION_BASIS_SQL, UNPAID_COMMISSION_FILTER), // NOSONAR
agentId, payingRunId);
if (basis.isEmpty()) {
basis = this.jdbcTemplate.queryForList(String.format(COMMISSION_BASIS_SQL, ""), agentId, payingRunId); // NOSONAR
}
if (basis.isEmpty()) {
throw new DdsLedgerNotConfiguredException("a collection by this agent to tell which DDS product's Agent Commission Expense "
+ "account the commission belongs to");
}
final Map<Long, GLAccount> accounts = new LinkedHashMap<>();
final Map<Long, BigDecimal> volumes = new LinkedHashMap<>();
BigDecimal total = BigDecimal.ZERO;
for (final Map<String, Object> row : basis) {
final String product = (String) row.get("product");
final Long glId = row.get("glId") == null ? null : ((Number) row.get("glId")).longValue();
final GLAccount account = accountNamed(glId, product, "Agent Commission Expense");
final BigDecimal volume = (BigDecimal) row.get("volume");
accounts.putIfAbsent(account.getId(), account);
volumes.merge(account.getId(), volume, BigDecimal::add);
total = total.add(volume);
}
final Map<GLAccount, BigDecimal> shares = new LinkedHashMap<>();
BigDecimal allotted = BigDecimal.ZERO;
final List<Long> ids = List.copyOf(volumes.keySet());
for (int i = 0; i < ids.size(); i++) {
final Long id = ids.get(i);
final BigDecimal share = i == ids.size() - 1 || total.signum() == 0 ? amount.subtract(allotted)
: amount.multiply(volumes.get(id)).divide(total, 2, RoundingMode.HALF_UP);
allotted = allotted.add(share);
if (share.signum() != 0) {
shares.put(accounts.get(id), share);
}
}
return shares;
}
/** Marks the collections a commission payment was shared out on, so the next payment starts after them. */
public void markCommissionPaid(final Long agentId, final Long batchRunId) {
this.jdbcTemplate.update(MARK_COMMISSION_PAID_SQL, batchRunId, agentId, batchRunId); // NOSONAR
} }
/** /**
@@ -490,15 +579,9 @@ public class DdsLedger {
public void reverseInterest(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description) { public void reverseInterest(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, final String description) {
final SavingsAccount account = load(savingsAccountId); final SavingsAccount account = load(savingsAccountId);
// DDS's own movement, so the debit block comes off for it and goes straight back on. final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
final boolean wasBlocked = unlockDebitsIfBlocked(account);
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account),
productAccount(account.productId(), INTEREST_ON_SAVINGS), description, null, account.getId()); productAccount(account.productId(), INTEREST_ON_SAVINGS), description, withdrawal.getId(), account.getId());
} }
/** /**
@@ -515,15 +598,15 @@ public class DdsLedger {
// The scheme account is blocked for debit so nobody can take the money out at a counter. This // The scheme account is blocked for debit so nobody can take the money out at a counter. This
// movement is DDS's own, so the block comes off for it and goes straight back on. // movement is DDS's own, so the block comes off for it and goes straight back on.
final boolean wasBlocked = unlockDebitsIfBlocked(from); final SavingsAccountTransaction withdrawal = withdraw(from, date, amount);
final SavingsTransactionBooleanValues withdrawalFlags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
this.savingsAccountDomainService.handleWithdrawal(from, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, withdrawalFlags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(from, wasBlocked);
final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount); final SavingsAccountTransaction deposit = depositWithoutAccounting(to, date, amount);
postPair(to, deposit, date, amount, controlAccountOf(from), controlAccountOf(to), description); // One leg on each account, each carrying its own account transaction, so both sides of the
// movement can be traced from the journal - not only the deposit.
writeLeg(from.office(), from.getCurrency().getCode(), "S" + withdrawal.getId(), date, amount, controlAccountOf(from),
JournalEntryType.DEBIT, description, withdrawal.getId(), from.getId());
writeLeg(from.office(), from.getCurrency().getCode(), "S" + withdrawal.getId(), date, amount, controlAccountOf(to),
JournalEntryType.CREDIT, description, deposit.getId(), to.getId());
} }
/** /**
@@ -556,15 +639,9 @@ public class DdsLedger {
final GLAccount parkIn) { final GLAccount parkIn) {
final SavingsAccount account = load(savingsAccountId); final SavingsAccount account = load(savingsAccountId);
// DDS's own movement, so the debit block comes off for it and goes straight back on. final SavingsAccountTransaction withdrawal = withdraw(account, date, amount);
final boolean wasBlocked = unlockDebitsIfBlocked(account); postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), parkIn, description,
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true); withdrawal.getId(), account.getId());
this.savingsAccountDomainService.handleWithdrawal(account, DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags,
this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
postPair(account.office(), account.getCurrency().getCode(), date, amount, controlAccountOf(account), parkIn,
description, null, account.getId());
} }
/** /**
@@ -592,12 +669,18 @@ public class DdsLedger {
return deposit; return deposit;
} }
/** Credits an agent's own savings account with commission the bank owes them. */ /**
* Credits an agent's own savings account with commission the bank owes them - one deposit to the
* agent, and one entry per product on that product's commission account (see commissionSplit).
*/
public SavingsAccountTransaction payCommission(final Long savingsAccountId, final LocalDate date, final BigDecimal amount, public SavingsAccountTransaction payCommission(final Long savingsAccountId, final LocalDate date, final BigDecimal amount,
final String description) { final String description, final Map<GLAccount, BigDecimal> shares) {
final SavingsAccount account = load(savingsAccountId); final SavingsAccount account = load(savingsAccountId);
final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount); final SavingsAccountTransaction deposit = depositWithoutAccounting(account, date, amount);
postPair(account, deposit, date, amount, configured(COMMISSION_EXPENSE), controlAccountOf(account), description); final GLAccount control = controlAccountOf(account);
for (final Map.Entry<GLAccount, BigDecimal> share : shares.entrySet()) {
postPair(account, deposit, date, share.getValue(), share.getKey(), control, description);
}
return deposit; return deposit;
} }
@@ -667,47 +750,60 @@ public class DdsLedger {
private void postPair(final Office office, final String currencyCode, final LocalDate date, final BigDecimal amount, private void postPair(final Office office, final String currencyCode, final LocalDate date, final BigDecimal amount,
final GLAccount debit, final GLAccount credit, final String description, final Long savingsTransactionId, final GLAccount debit, final GLAccount credit, final String description, final Long savingsTransactionId,
final Long savingsAccountId) { final Long savingsAccountId) {
final String transactionId = savingsTransactionId == null ? "DDS" + date + amount : "S" + savingsTransactionId; final String transactionId = savingsTransactionId == null ? "DDS-" + UUID.randomUUID() : "S" + savingsTransactionId;
writePair(office, currencyCode, transactionId, date, amount, debit, credit, description, savingsTransactionId, savingsAccountId); writePair(office, currencyCode, transactionId, date, amount, debit, credit, description, savingsTransactionId, savingsAccountId);
} }
private void writePair(final Office office, final String currencyCode, final String transactionId, final LocalDate date, private void writePair(final Office office, final String currencyCode, final String transactionId, final LocalDate date,
final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description, final BigDecimal amount, final GLAccount debit, final GLAccount credit, final String description,
final Long savingsTransactionId, final Long savingsAccountId) { final Long savingsTransactionId, final Long savingsAccountId) {
// Noted for the voucher of the operation this leg belongs to - see DdsVoucherService. writeLeg(office, currencyCode, transactionId, date, amount, debit, JournalEntryType.DEBIT, description, savingsTransactionId,
DdsVoucherScope.recordLedgerTransaction(transactionId); savingsAccountId);
for (final JournalEntryType type : List.of(JournalEntryType.DEBIT, JournalEntryType.CREDIT)) { writeLeg(office, currencyCode, transactionId, date, amount, credit, JournalEntryType.CREDIT, description, savingsTransactionId,
this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null, savingsAccountId);
JournalEntryType.DEBIT.equals(type) ? debit : credit, currencyCode, transactionId, false, date, type, amount,
description, PortfolioProductType.SAVING.getValue(), savingsAccountId, null, null, savingsTransactionId, null, null));
}
} }
/** A ledger account named by a setting, so a bank can point it at one of their own. */ private void writeLeg(final Office office, final String currencyCode, final String transactionId, final LocalDate date,
private GLAccount configured(final String settingName) { final BigDecimal amount, final GLAccount account, final JournalEntryType type, final String description,
final List<Long> ids = this.jdbcTemplate.queryForList(CONFIGURED_ACCOUNT_SQL, Long.class, settingName); // NOSONAR final Long savingsTransactionId, final Long savingsAccountId) {
if (ids.isEmpty() || ids.get(0) == null) { // Noted for the voucher of the operation this leg belongs to - see DdsVoucherService - and as the
throw new DdsLedgerNotConfiguredException(settingName); // journal the next passbook line of the operation is linked to (m_dds_party_transaction.journal_transaction_id).
DdsVoucherScope.recordLedgerTransaction(transactionId);
this.journalEntryRepository.saveAndFlush(JournalEntry.createNew(office, null, account, currencyCode, transactionId, false, date, type,
amount, description, PortfolioProductType.SAVING.getValue(), savingsAccountId, null, null, savingsTransactionId, null,
null));
} }
return this.glAccountRepository.findById(ids.get(0)).orElseThrow(() -> new DdsLedgerNotConfiguredException(settingName));
/**
* A journal id for a leg on DDS's own books - the party's code to find it by and a full UUID to keep it
* unique: DDS-PT0003-1b4e... Never built from the date and amount, which two parties can share.
*/
private static String partyLegId(final Party party) {
return "DDS-" + party.getPartyCode() + "-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
/** The branch a party's legs are booked at: the party's own, or its agent's for a party opened without one. */
public Office officeOf(final Party party) {
return party.getOffice() != null ? party.getOffice() : party.getAgent().getOffice();
}
/**
* Takes money off a scheme account that DDS keeps blocked for debits, as DDS's own movement - the block
* comes off for it and goes straight back on - and returns the withdrawal so its journal can name it.
*/
private SavingsAccountTransaction withdraw(final SavingsAccount account, final LocalDate date, final BigDecimal amount) {
final boolean wasBlocked = unlockDebitsIfBlocked(account);
final SavingsTransactionBooleanValues flags = new SavingsTransactionBooleanValues(false, true, false, false, false, true);
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(account,
DateUtils.DEFAULT_DATE_FORMATTER, date, amount, null, flags, this.savingsAccountAssembler.getPivotConfigStatus());
relockDebits(account, wasBlocked);
return withdrawal;
} }
private GLAccount controlAccountOf(final SavingsAccount account) { private GLAccount controlAccountOf(final SavingsAccount account) {
return productAccount(account.productId(), CONTROL_ACCOUNT); return productAccount(account.productId(), CONTROL_ACCOUNT);
} }
/**
* The savings product DDS opens customer accounts under. A settlement touches no single customer,
* so its ledger accounts and currency come from the product rather than from an account.
*/
private Long depositProductId() {
final List<Long> ids = this.jdbcTemplate.queryForList(CONFIGURED_ACCOUNT_SQL, Long.class, DEPOSIT_PRODUCT); // NOSONAR
if (ids.isEmpty() || ids.get(0) == null) {
throw new DdsLedgerNotConfiguredException(DEPOSIT_PRODUCT);
}
return ids.get(0);
}
/** The bank's own currency, for an entry that belongs to no savings product. */ /** The bank's own currency, for an entry that belongs to no savings product. */
private String organisationCurrency() { private String organisationCurrency() {
final List<String> codes = this.jdbcTemplate.queryForList(ORGANISATION_CURRENCY_SQL, String.class); // NOSONAR final List<String> codes = this.jdbcTemplate.queryForList(ORGANISATION_CURRENCY_SQL, String.class); // NOSONAR
@@ -717,14 +813,6 @@ public class DdsLedger {
return codes.get(0); return codes.get(0);
} }
/** Read off the product rather than assumed, so a bank on another currency is not silently wrong. */
private String currencyOf(final Long productId) {
final List<String> codes = this.jdbcTemplate.queryForList(PRODUCT_CURRENCY_SQL, String.class, productId); // NOSONAR
if (codes.isEmpty()) {
throw new DdsLedgerNotConfiguredException("currency for savings product " + productId);
}
return codes.get(0);
}
/** An account off the savings product's own mapping, so this stays right per product. */ /** An account off the savings product's own mapping, so this stays right per product. */
private GLAccount productAccount(final Long productId, final int financialAccountType) { private GLAccount productAccount(final Long productId, final int financialAccountType) {
@@ -22,6 +22,7 @@ import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@@ -335,6 +336,9 @@ public class DdsVoucherService {
LAST_ISSUED.set(voucherNo); LAST_ISSUED.set(voucherNo);
stamp("update m_dds_party_transaction set voucher_no = ? where id in (%s)", voucherNo, scope.getPartyTransactionIds()); stamp("update m_dds_party_transaction set voucher_no = ? where id in (%s)", voucherNo, scope.getPartyTransactionIds());
// The journal legs carry the voucher too, in ref_num, so the slip, the passbook lines and the
// journal can each be found from the others - as a savings voucher's legs can.
stampLegs(voucherNo, scope.getLedgerTransactionIds());
stamp("update m_savings_account_transaction set voucher_no = ? where voucher_no is null and id in (%s)", voucherNo, stamp("update m_savings_account_transaction set voucher_no = ? where voucher_no is null and id in (%s)", voucherNo,
savingsTransactions); savingsTransactions);
} }
@@ -356,7 +360,7 @@ public class DdsVoucherService {
return new ArrayList<>(); return new ArrayList<>();
} }
final Long depositGlId = depositLiabilityGlId(); final Set<Long> depositGlIds = depositLiabilityGlIds();
final Map<String, Map<String, Object>> parties = new LinkedHashMap<>(); final Map<String, Map<String, Object>> parties = new LinkedHashMap<>();
for (final Map<String, Object> leg : this.jdbcTemplate.queryForList(String.format(LEGS_SQL, placeholders(transactionIds)), // NOSONAR for (final Map<String, Object> leg : this.jdbcTemplate.queryForList(String.format(LEGS_SQL, placeholders(transactionIds)), // NOSONAR
transactionIds.toArray())) { transactionIds.toArray())) {
@@ -383,7 +387,7 @@ public class DdsVoucherService {
} }
final Matcher partyLeg = PARTY_LEG.matcher(String.valueOf(leg.get("transactionId"))); final Matcher partyLeg = PARTY_LEG.matcher(String.valueOf(leg.get("transactionId")));
if (partyLeg.matches() && glId.equals(depositGlId)) { if (partyLeg.matches() && depositGlIds.contains(glId)) {
final String partyCode = partyLeg.group(1); final String partyCode = partyLeg.group(1);
final Map<String, Object> party = parties.computeIfAbsent(partyCode, final Map<String, Object> party = parties.computeIfAbsent(partyCode,
code -> this.jdbcTemplate.queryForList(PARTY_SQL, code).stream().findFirst().orElse(Map.of())); // NOSONAR code -> this.jdbcTemplate.queryForList(PARTY_SQL, code).stream().findFirst().orElse(Map.of())); // NOSONAR
@@ -417,11 +421,24 @@ public class DdsVoucherService {
return lines; return lines;
} }
/** The GL that carries what DDS customers are owed - a leg on it is the customer's own. */ /**
private Long depositLiabilityGlId() { * The GLs that carry what DDS customers are owed - a leg on one is the customer's own. Each DDS
final List<Long> ids = this.jdbcTemplate.queryForList( // NOSONAR * product names its own on its Accounting tab (0620).
"select value from c_configuration where name = 'dds-deposit-liability-account' and enabled = true", Long.class); */
return ids.isEmpty() ? null : ids.get(0); private Set<Long> depositLiabilityGlIds() {
return new HashSet<>(this.jdbcTemplate.queryForList( // NOSONAR
"select deposit_liability_gl_id from m_dds_account_type where deposit_liability_gl_id is not null", Long.class));
}
private void stampLegs(final String voucherNo, final Collection<String> transactionIds) {
if (transactionIds.isEmpty()) {
return;
}
final List<Object> args = new ArrayList<>();
args.add(voucherNo);
args.addAll(transactionIds);
this.jdbcTemplate.update("update acc_gl_journal_entry set ref_num = ? where ref_num is null and transaction_id in (" // NOSONAR
+ placeholders(transactionIds) + ")", args.toArray());
} }
private void stamp(final String sql, final String voucherNo, final Collection<Long> ids) { private void stamp(final String sql, final String voucherNo, final Collection<Long> ids) {
@@ -27,11 +27,7 @@ public interface DdsAccountTypeRepository extends JpaRepository<DdsAccountType,
Optional<DdsAccountType> findByAccountTypeCode(String accountTypeCode); Optional<DdsAccountType> findByAccountTypeCode(String accountTypeCode);
/** /** Schemes that post interest on their own, at the end of each posting period. */
* Schemes that post interest on their own. A posting day is required to act on one, so a scheme @Query("select t from DdsAccountType t where t.active = true and t.autoInterestPost = true order by t.accountTypeCode")
* left without a day is not picked up - it would otherwise mean "post monthly, some day".
*/
@Query("select t from DdsAccountType t where t.active = true and t.autoInterestPost = true "
+ "and t.autoInterestPostDay is not null order by t.accountTypeCode")
List<DdsAccountType> findDueForAutoInterestPost(); List<DdsAccountType> findDueForAutoInterestPost();
} }
@@ -39,9 +39,15 @@ import org.springframework.stereotype.Component;
@Component @Component
public final class DdsAccountTypeCommandFromApiJsonDeserializer { public final class DdsAccountTypeCommandFromApiJsonDeserializer {
/*
* "autoInterestPostDay" is still accepted, and ignored, so a change held for approval before the
* posting day was retired - or an older screen still sending it - is not refused. Interest now
* posts at the end of each period, so there is no day to set.
*/
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode", private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(Arrays.asList("accountTypeGroup", "accountTypeCode",
"accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active", "accountTypeDescription", "autoInterestPost", "autoInterestPostWhen", "autoInterestPostDay", "maturityAutoClose", "active",
"prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty", "interestRate", "prematureLockinMonths", "prematureInterestReduction", "prematureAgentCommissionPenalty", "interestRate",
"depositLiabilityGlId", "interestExpenseGlId", "commissionExpenseGlId",
"dateFormat", "locale")); "dateFormat", "locale"));
/** A rate, not an amount - nothing here may exceed the whole of what it is taken from. */ /** A rate, not an amount - nothing here may exceed the whole of what it is taken from. */
@@ -66,6 +72,14 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
v.reset().parameter("accountTypeDescription") v.reset().parameter("accountTypeDescription")
.value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank() .value(this.fromApiJsonHelper.extractStringNamed("accountTypeDescription", element)).notBlank()
.notExceedingLengthOf(200); .notExceedingLengthOf(200);
// Mandatory, as a savings product's control and interest accounts are: every posting for the
// product's customers and agents goes through them, with nothing bank-wide behind them.
v.reset().parameter("depositLiabilityGlId").value(this.fromApiJsonHelper.extractLongNamed("depositLiabilityGlId", element))
.notNull().integerGreaterThanZero();
v.reset().parameter("interestExpenseGlId").value(this.fromApiJsonHelper.extractLongNamed("interestExpenseGlId", element))
.notNull().integerGreaterThanZero();
v.reset().parameter("commissionExpenseGlId").value(this.fromApiJsonHelper.extractLongNamed("commissionExpenseGlId", element))
.notNull().integerGreaterThanZero();
// Mandatory: every account opened on the scheme takes this rate. // Mandatory: every account opened on the scheme takes this rate.
v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element)) v.reset().parameter("interestRate").value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("interestRate", element))
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED); .notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
@@ -99,6 +113,14 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
.notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED); .notNull().zeroOrPositiveAmount().notGreaterThanMax(HUNDRED);
} }
// Once named, a product's customer and interest accounts can be changed but not cleared.
for (final String required : List.of("depositLiabilityGlId", "interestExpenseGlId", "commissionExpenseGlId")) {
if (this.fromApiJsonHelper.parameterExists(required, element)) {
v.reset().parameter(required).value(this.fromApiJsonHelper.extractLongNamed(required, element)).notNull()
.integerGreaterThanZero();
}
}
validateOptionalFields(element, v); validateOptionalFields(element, v);
throwExceptionIfValidationWarningsExist(dataValidationErrors); throwExceptionIfValidationWarningsExist(dataValidationErrors);
@@ -121,11 +143,6 @@ public final class DdsAccountTypeCommandFromApiJsonDeserializer {
.value(this.fromApiJsonHelper.extractStringNamed("autoInterestPostWhen", element)).ignoreIfNull() .value(this.fromApiJsonHelper.extractStringNamed("autoInterestPostWhen", element)).ignoreIfNull()
.notExceedingLengthOf(20); .notExceedingLengthOf(20);
} }
if (this.fromApiJsonHelper.parameterExists("autoInterestPostDay", element)) {
v.reset().parameter("autoInterestPostDay")
.value(this.fromApiJsonHelper.extractIntegerSansLocaleNamed("autoInterestPostDay", element)).ignoreIfNull()
.inMinMaxRange(1, 31);
}
// Premature closure terms. Zero is allowed throughout - a scheme may close early from day // Premature closure terms. Zero is allowed throughout - a scheme may close early from day
// one, or charge the customer or the agent nothing - so these are floored at zero, not at one. // one, or charge the customer or the agent nothing - so these are floored at zero, not at one.
@@ -43,15 +43,24 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead
public String schema() { public String schema() {
return " t.id as id, t.account_type_group as accountTypeGroup, t.account_type_code as accountTypeCode, " return " t.id as id, t.account_type_group as accountTypeGroup, t.account_type_code as accountTypeCode, "
+ " t.account_type_description as accountTypeDescription, t.auto_interest_post as autoInterestPost, " + " t.account_type_description as accountTypeDescription, t.auto_interest_post as autoInterestPost, "
+ " t.auto_interest_post_when as autoInterestPostWhen, t.auto_interest_post_day as autoInterestPostDay, " + " t.auto_interest_post_when as autoInterestPostWhen, "
+ " t.maturity_auto_close as maturityAutoClose, " + " t.maturity_auto_close as maturityAutoClose, "
+ " t.premature_lockin_months as prematureLockinMonths, " + " t.premature_lockin_months as prematureLockinMonths, "
+ " t.premature_interest_reduction as prematureInterestReduction, " + " t.premature_interest_reduction as prematureInterestReduction, "
+ " t.premature_agent_commission_penalty as prematureAgentCommissionPenalty, " + " t.premature_agent_commission_penalty as prematureAgentCommissionPenalty, "
+ " t.interest_rate as interestRate, " + " t.interest_rate as interestRate, "
+ " t.is_active as active, " + " t.is_active as active, "
+ " t.created_date as createdDate, t.lastmodified_date as lastModifiedDate " + " t.created_date as createdDate, t.lastmodified_date as lastModifiedDate, "
+ " from m_dds_account_type t"; + " t.deposit_liability_gl_id as depositLiabilityGlId, dl.gl_code as depositLiabilityGlCode, "
+ " dl.name as depositLiabilityGlName, "
+ " t.interest_expense_gl_id as interestExpenseGlId, ie.gl_code as interestExpenseGlCode, "
+ " ie.name as interestExpenseGlName, "
+ " t.commission_expense_gl_id as commissionExpenseGlId, ce.gl_code as commissionExpenseGlCode, "
+ " ce.name as commissionExpenseGlName "
+ " from m_dds_account_type t "
+ " left join acc_gl_account dl on dl.id = t.deposit_liability_gl_id "
+ " left join acc_gl_account ie on ie.id = t.interest_expense_gl_id "
+ " left join acc_gl_account ce on ce.id = t.commission_expense_gl_id";
} }
@Override @Override
@@ -63,13 +72,21 @@ public class DdsAccountTypeReadPlatformServiceImpl implements DdsAccountTypeRead
.accountTypeDescription(rs.getString("accountTypeDescription")) // .accountTypeDescription(rs.getString("accountTypeDescription")) //
.autoInterestPost(rs.getBoolean("autoInterestPost")) // .autoInterestPost(rs.getBoolean("autoInterestPost")) //
.autoInterestPostWhen(rs.getString("autoInterestPostWhen")) // .autoInterestPostWhen(rs.getString("autoInterestPostWhen")) //
.autoInterestPostDay(JdbcSupport.getInteger(rs, "autoInterestPostDay")) //
.maturityAutoClose(rs.getBoolean("maturityAutoClose")) // .maturityAutoClose(rs.getBoolean("maturityAutoClose")) //
.prematureLockinMonths(JdbcSupport.getInteger(rs, "prematureLockinMonths")) // .prematureLockinMonths(JdbcSupport.getInteger(rs, "prematureLockinMonths")) //
.prematureInterestReduction(rs.getBigDecimal("prematureInterestReduction")) // .prematureInterestReduction(rs.getBigDecimal("prematureInterestReduction")) //
.prematureAgentCommissionPenalty(rs.getBigDecimal("prematureAgentCommissionPenalty")) // .prematureAgentCommissionPenalty(rs.getBigDecimal("prematureAgentCommissionPenalty")) //
.interestRate(rs.getBigDecimal("interestRate")) // .interestRate(rs.getBigDecimal("interestRate")) //
.active(rs.getBoolean("active")) // .active(rs.getBoolean("active")) //
.depositLiabilityGlId(JdbcSupport.getLong(rs, "depositLiabilityGlId")) //
.depositLiabilityGlCode(rs.getString("depositLiabilityGlCode")) //
.depositLiabilityGlName(rs.getString("depositLiabilityGlName")) //
.interestExpenseGlId(JdbcSupport.getLong(rs, "interestExpenseGlId")) //
.interestExpenseGlCode(rs.getString("interestExpenseGlCode")) //
.interestExpenseGlName(rs.getString("interestExpenseGlName")) //
.commissionExpenseGlId(JdbcSupport.getLong(rs, "commissionExpenseGlId")) //
.commissionExpenseGlCode(rs.getString("commissionExpenseGlCode")) //
.commissionExpenseGlName(rs.getString("commissionExpenseGlName")) //
.createdDate(localDateTime(rs, "createdDate")) // .createdDate(localDateTime(rs, "createdDate")) //
.lastModifiedDate(localDateTime(rs, "lastModifiedDate")) // .lastModifiedDate(localDateTime(rs, "lastModifiedDate")) //
.build(); .build();
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.dds.accounttype.service; package org.apache.fineract.portfolio.dds.accounttype.service;
import jakarta.persistence.PersistenceException; import jakarta.persistence.PersistenceException;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -35,7 +36,10 @@ import org.apache.fineract.portfolio.dds.accounttype.exception.DdsAccountTypeNot
import org.apache.fineract.portfolio.dds.accounttype.serialization.DdsAccountTypeCommandFromApiJsonDeserializer; import org.apache.fineract.portfolio.dds.accounttype.serialization.DdsAccountTypeCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.dds.party.domain.Party; 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.domain.PartyRepository;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaSystemException; import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -46,6 +50,15 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
private final DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer; private final DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer;
private final DdsAccountTypeRepository ddsAccountTypeRepository; private final DdsAccountTypeRepository ddsAccountTypeRepository;
private final PartyRepository partyRepository; private final PartyRepository partyRepository;
private final JdbcTemplate jdbcTemplate;
private static final String GL_SQL = "select classification_enum, account_usage, disabled, gl_code, name from acc_gl_account where id = ?";
/** acc_gl_account.classification_enum: 2 liability, 5 expense. */
private static final int LIABILITY = 2;
private static final int EXPENSE = 5;
/** acc_gl_account.account_usage 1 - a detail account an entry can be posted to, not a header. */
private static final int DETAIL = 1;
@Transactional @Transactional
@Override @Override
@@ -53,6 +66,7 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
try { try {
this.fromApiJsonDeserializer.validateForCreate(command.json()); this.fromApiJsonDeserializer.validateForCreate(command.json());
validateLedgerAccounts(command);
final DdsAccountType accountType = DdsAccountType.fromJson(command); final DdsAccountType accountType = DdsAccountType.fromJson(command);
this.ddsAccountTypeRepository.saveAndFlush(accountType); this.ddsAccountTypeRepository.saveAndFlush(accountType);
@@ -77,6 +91,7 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
try { try {
this.fromApiJsonDeserializer.validateForUpdate(command.json()); this.fromApiJsonDeserializer.validateForUpdate(command.json());
validateLedgerAccounts(command);
final DdsAccountType accountType = this.ddsAccountTypeRepository.findById(accountTypeId) final DdsAccountType accountType = this.ddsAccountTypeRepository.findById(accountTypeId)
.orElseThrow(() -> new DdsAccountTypeNotFoundException(accountTypeId)); .orElseThrow(() -> new DdsAccountTypeNotFoundException(accountTypeId));
@@ -100,6 +115,13 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
changesOnly.put("accountsRepriced", openAccounts.size()); changesOnly.put("accountsRepriced", openAccounts.size());
} }
// A renamed product: the accounts on it - tied to it by id - show the new code.
if (changesOnly.containsKey("accountTypeCode")) {
final int moved = this.jdbcTemplate.update("update m_dds_party set sub_type = ? where account_type_id = ?", // NOSONAR
accountType.getAccountTypeCode(), accountTypeId);
changesOnly.put("accountsRenamed", moved);
}
return new CommandProcessingResultBuilder() // return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) // .withCommandId(command.commandId()) //
.withEntityId(accountTypeId) // .withEntityId(accountTypeId) //
@@ -115,6 +137,47 @@ public class DdsAccountTypeWritePlatformServiceJpaRepositoryImpl implements DdsA
} }
} }
/**
* Each ledger account a product names must be one an entry can land on, and of the kind its job
* needs - the same rule the savings product's Accounting tab applies by offering only those. The
* customers' money is owed, so a liability; their interest and the agents' commission are costs, so
* expenses.
*/
private void validateLedgerAccounts(final JsonCommand command) {
final List<ApiParameterError> errors = new ArrayList<>();
checkLedgerAccount(command, "depositLiabilityGlId", "Customer Deposits", List.of(LIABILITY), errors);
checkLedgerAccount(command, "interestExpenseGlId", "Interest Expense", List.of(EXPENSE), errors);
checkLedgerAccount(command, "commissionExpenseGlId", "Agent Commission Expense", List.of(EXPENSE), errors);
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
private void checkLedgerAccount(final JsonCommand command, final String parameter, final String label, final List<Integer> kinds,
final List<ApiParameterError> errors) {
final Long glId = command.parameterExists(parameter) ? command.longValueOfParameterNamed(parameter) : null;
if (glId == null) {
return;
}
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(GL_SQL, glId); // NOSONAR
final String code = "error.msg.dds.account.type." + parameter + ".invalid";
if (rows.isEmpty()) {
errors.add(ApiParameterError.parameterError(code, label + ": GL account " + glId + " does not exist.", parameter));
return;
}
final Map<String, Object> gl = rows.get(0);
final String name = gl.get("name") + " (" + gl.get("gl_code") + ")";
if (Boolean.TRUE.equals(gl.get("disabled"))) {
errors.add(ApiParameterError.parameterError(code, label + ": " + name + " is disabled.", parameter));
} else if (((Number) gl.get("account_usage")).intValue() != DETAIL) {
errors.add(ApiParameterError.parameterError(code, label + ": " + name + " is a header account and cannot take entries.",
parameter));
} else if (!kinds.contains(((Number) gl.get("classification_enum")).intValue())) {
errors.add(ApiParameterError.parameterError(code, label + ": " + name + " is not "
+ (kinds.get(0) == LIABILITY ? "a liability" : "an expense") + " account.", parameter));
}
}
private void handleDataIntegrityIssues(final Throwable realCause, final Exception dve) { private void handleDataIntegrityIssues(final Throwable realCause, final Exception dve) {
if (realCause.getMessage() != null && realCause.getMessage().contains("account_type_code")) { if (realCause.getMessage() != null && realCause.getMessage().contains("account_type_code")) {
throw new PlatformDataIntegrityException("error.msg.dds.account.type.duplicate.code", throw new PlatformDataIntegrityException("error.msg.dds.account.type.duplicate.code",
@@ -43,7 +43,8 @@ public class DdsAccountTypeConfiguration {
@ConditionalOnMissingBean(DdsAccountTypeWritePlatformService.class) @ConditionalOnMissingBean(DdsAccountTypeWritePlatformService.class)
public DdsAccountTypeWritePlatformService ddsAccountTypeWritePlatformService( public DdsAccountTypeWritePlatformService ddsAccountTypeWritePlatformService(
DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer, DdsAccountTypeRepository ddsAccountTypeRepository, DdsAccountTypeCommandFromApiJsonDeserializer fromApiJsonDeserializer, DdsAccountTypeRepository ddsAccountTypeRepository,
PartyRepository partyRepository) { PartyRepository partyRepository, JdbcTemplate jdbcTemplate) {
return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository, partyRepository); return new DdsAccountTypeWritePlatformServiceJpaRepositoryImpl(fromApiJsonDeserializer, ddsAccountTypeRepository, partyRepository,
jdbcTemplate);
} }
} }
@@ -26,9 +26,12 @@ import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException; import org.apache.fineract.commands.exception.CommandNotAwaitingApprovalException;
import org.apache.fineract.commands.exception.CommandNotFoundException; import org.apache.fineract.commands.exception.CommandNotFoundException;
import org.apache.fineract.portfolio.dds.accounting.DdsLedger;
import org.apache.fineract.portfolio.dds.accounting.exception.DdsLedgerNotConfiguredException;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -63,6 +66,7 @@ public class DdsApprovalReadService {
private static final String REF_KEY_PREFIX = "DDS_COMMAND:"; private static final String REF_KEY_PREFIX = "DDS_COMMAND:";
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final DdsLedger ddsLedger;
public List<Map<String, Object>> list(final String status, final String fromDate, final String toDate) { public List<Map<String, Object>> list(final String status, final String fromDate, final String toDate) {
final StringBuilder sql = new StringBuilder(ENTRIES_SQL).append(" and c.status = ? "); final StringBuilder sql = new StringBuilder(ENTRIES_SQL).append(" and c.status = ? ");
@@ -95,9 +99,423 @@ public class DdsApprovalReadService {
final Map<String, Object> row = describe(found.get(0)); final Map<String, Object> row = describe(found.get(0));
row.put("description", text(jsonOf(found.get(0).get("json")), "withdrawDescription")); row.put("description", text(jsonOf(found.get(0).get("json")), "withdrawDescription"));
row.put("lines", linesFor(row, found.get(0))); row.put("lines", linesFor(row, found.get(0)));
row.put("fields", fieldsFor(row, found.get(0)));
if (isAgentRequest(row)) {
row.put("guarantors", guarantorsOf(jsonOf(found.get(0).get("json"))));
}
if (isPartyRequest(row)) {
final JsonObject partyJson = jsonOf(found.get(0).get("json"));
row.put("nominees", nomineesOf(partyJson));
row.put("nomineeSplit", text(partyJson, "nomineeSplitMode"));
}
return row; return row;
} }
/** A DDS account (party) being opened or changed - the entries whose review shows the account and its nominees. */
private static boolean isPartyRequest(final Map<String, Object> row) {
return "PARTY".equals(row.get("entity")) && ("CREATE".equals(row.get("action")) || "UPDATE".equals(row.get("action")));
}
/** What a DDS account request carries, in the order the Party Master Entry screen asks for it. */
private record PartyField(String parameter, String label, String group, String kind) {}
private static final String PARTY_GROUP_CUSTOMER = "Customer & Scheme";
private static final String PARTY_GROUP_PERSONAL = "Personal & Contact";
private static final String PARTY_GROUP_MINOR = "Minor / Guardian";
private static final String PARTY_GROUP_TERMS = "Scheme Terms";
private static final String PARTY_GROUP_STATUS = "Balances & Status";
private static final String PARTY_GROUP_PAYOUT = "Maturity Payout";
private static final List<PartyField> PARTY_FIELDS = List.of( //
new PartyField("clientId", "Client", PARTY_GROUP_CUSTOMER, "client"), //
new PartyField("subType", "Scheme", PARTY_GROUP_CUSTOMER, "scheme"), //
new PartyField("agentId", "Agent", PARTY_GROUP_CUSTOMER, "agent"), //
new PartyField("officeId", "Branch", PARTY_GROUP_CUSTOMER, "office"), //
new PartyField("nominal", "Nominal", PARTY_GROUP_CUSTOMER, "yesno"), //
new PartyField("partyName", "Party Name", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("partyName1", "Party Name 1", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("address1", "Address 1", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("address2", "Address 2", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("address3", "Address 3", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("city", "City", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("pincode", "Pincode", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("gender", "Gender", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("telephone", "Telephone", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("mobileNoSms", "Mobile No (SMS)", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("aadharNo", "Aadhar No", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("panCardNo", "PAN Card No", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("ckycId", "CKYC ID", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("operation", "Operation", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("birthDate", "Birth Date", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("occupation", "Occupation", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("risk", "Risk", PARTY_GROUP_PERSONAL, "text"), //
new PartyField("adultMinor", "Adult / Minor", PARTY_GROUP_MINOR, "text"), //
new PartyField("guardianClientId", "Guardian", PARTY_GROUP_MINOR, "client"), //
new PartyField("relation", "Relation", PARTY_GROUP_MINOR, "text"), //
new PartyField("minorDate", "Comes of Age On", PARTY_GROUP_MINOR, "text"), //
new PartyField("minAccountOpt", "Minor Account Option", PARTY_GROUP_MINOR, "text"), //
new PartyField("openingDate", "Opening Date", PARTY_GROUP_TERMS, "text"), //
new PartyField("dailyDepositAmount", "Daily Deposit", PARTY_GROUP_TERMS, "money"), //
new PartyField("days", "Days", PARTY_GROUP_TERMS, "text"), //
new PartyField("month", "Months", PARTY_GROUP_TERMS, "text"), //
new PartyField("depositAmount", "Deposit Amount", PARTY_GROUP_TERMS, "money"), //
new PartyField("maturityDate", "Maturity Date", PARTY_GROUP_TERMS, "text"), //
new PartyField("ledgerNo", "Ledger No", PARTY_GROUP_TERMS, "text"), //
new PartyField("reopenDate", "Reopen Date", PARTY_GROUP_TERMS, "text"), //
new PartyField("freezeCode", "Freeze Code", PARTY_GROUP_STATUS, "text"), //
new PartyField("lien", "Lien", PARTY_GROUP_STATUS, "yesno"), //
new PartyField("lienReason", "Lien Reason", PARTY_GROUP_STATUS, "text"), //
new PartyField("lienReasonOther", "Lien Reason (Other)", PARTY_GROUP_STATUS, "text"), //
new PartyField("isSmsActive", "SMS Active", PARTY_GROUP_STATUS, "yesno"), //
new PartyField("isEmailActive", "Email Active", PARTY_GROUP_STATUS, "yesno"), //
new PartyField("od1Flag", "OD 1", PARTY_GROUP_STATUS, "yesno"), //
new PartyField("od2Flag", "OD 2", PARTY_GROUP_STATUS, "yesno"), //
new PartyField("payDate", "Pay Date", PARTY_GROUP_STATUS, "text"), //
new PartyField("interestDate", "Interest Date", PARTY_GROUP_STATUS, "text"), //
new PartyField("spDescription", "Description", PARTY_GROUP_STATUS, "text"), //
new PartyField("advCode", "Adv Code", PARTY_GROUP_PAYOUT, "text"), //
new PartyField("creditToBranch", "Credit To Branch", PARTY_GROUP_PAYOUT, "text"), //
new PartyField("creditToAccount", "Credit To A/C", PARTY_GROUP_PAYOUT, "text"), //
new PartyField("creditToAccountType", "Credit To A/C Type", PARTY_GROUP_PAYOUT, "text"));
/**
* A DDS account request laid out for the checker: the client, scheme, agent and branch by name
* rather than by number, in the sections the screen has. Only what the request carries is shown, so
* an edit lists just the fields it changes, and a field left empty is left out.
*/
private List<Map<String, Object>> partyFieldsFor(final JsonObject json) {
final List<Map<String, Object>> fields = new ArrayList<>();
for (final PartyField field : PARTY_FIELDS) {
if (!json.has(field.parameter()) || json.get(field.parameter()).isJsonNull()) {
continue;
}
final String raw = json.get(field.parameter()).getAsString();
if (raw.isBlank()) {
continue;
}
final String value = switch (field.kind()) {
case "client" -> clientLabel(raw);
case "scheme" -> nameOr(first("select account_type_code || ' · ' || account_type_description from m_dds_account_type "
+ "where account_type_code = ?", raw), raw);
case "agent" -> nameOr(first("select agent_code || ' · ' || coalesce(agent_name, '') from m_dds_agent where id = ?",
parseLong(raw)), "Agent #" + raw);
case "office" -> nameOr(first("select name from m_office where id = ?", parseLong(raw)), "Branch #" + raw);
case "yesno" -> Boolean.parseBoolean(raw.trim()) ? "Yes" : "No";
case "money" -> "₹" + plain(raw);
default -> raw.trim();
};
final Map<String, Object> out = new LinkedHashMap<>();
out.put("key", field.parameter());
out.put("label", field.label());
out.put("group", field.group());
out.put("format", "yesno".equals(field.kind()) ? "yesno" : "text");
out.put("value", value);
fields.add(out);
}
return fields;
}
/** The nominees a DDS account request names, with what each is to receive. */
private List<Map<String, Object>> nomineesOf(final JsonObject json) {
final List<Map<String, Object>> nominees = new ArrayList<>();
if (!json.has("nominees") || !json.get("nominees").isJsonArray()) {
return nominees;
}
for (final JsonElement element : json.getAsJsonArray("nominees")) {
if (!element.isJsonObject()) {
continue;
}
final JsonObject n = element.getAsJsonObject();
final Map<String, Object> out = new LinkedHashMap<>();
out.put("name", text(n, "name"));
out.put("relation", text(n, "relation"));
out.put("gender", text(n, "gender"));
out.put("mobileNo", text(n, "mobileNo"));
out.put("birthDate", text(n, "birthDate"));
out.put("nominationDate", text(n, "nominationDate"));
out.put("guardianName", text(n, "guardianName"));
out.put("share", text(n, "sharePercent") != null ? plain(text(n, "sharePercent")) + "%"
: text(n, "sequenceNo") != null ? "Order " + text(n, "sequenceNo") : null);
nominees.add(out);
}
return nominees;
}
/** An agent being opened or changed - the entries whose review shows the agent and their guarantors. */
private static boolean isAgentRequest(final Map<String, Object> row) {
return "AGENT".equals(row.get("entity")) && ("CREATE".equals(row.get("action")) || "UPDATE".equals(row.get("action")));
}
/** What an agent request carries, in the order the Agent Master Entry screen asks for it. */
private record AgentField(String parameter, String label, String group, String kind) {}
private static final String AGENT_GROUP = "Agent";
private static final String AGENT_ADDRESS_GROUP = "Address & Contact";
private static final String AGENT_ACCOUNTS_GROUP = "Branch & Accounts";
private static final String AGENT_BALANCES_GROUP = "Balances";
private static final List<AgentField> AGENT_FIELDS = List.of( //
new AgentField("clientId", "Client", AGENT_GROUP, "client"), //
new AgentField("agentNameId", "Agent Name", AGENT_GROUP, "text"), //
new AgentField("agentBirthDate", "Birth Date", AGENT_GROUP, "text"), //
new AgentField("agentOpeningDate", "Opening Date", AGENT_GROUP, "text"), //
new AgentField("agentAddress1", "Address (Line 1)", AGENT_ADDRESS_GROUP, "text"), //
new AgentField("agentAddress2", "Address (Line 2)", AGENT_ADDRESS_GROUP, "text"), //
new AgentField("agentCity", "City", AGENT_ADDRESS_GROUP, "text"), //
new AgentField("agentPinCode", "PinCode", AGENT_ADDRESS_GROUP, "text"), //
new AgentField("agentTelephone", "Telephone", AGENT_ADDRESS_GROUP, "text"), //
new AgentField("officeId", "Home Branch", AGENT_ACCOUNTS_GROUP, "office"), //
new AgentField("glAccountId", "GL Account", AGENT_ACCOUNTS_GROUP, "gl"), //
new AgentField("agentSavingAccountType", "Saving A/C Type", AGENT_ACCOUNTS_GROUP, "text"), //
new AgentField("agentSavingAccountNo", "Saving A/C No", AGENT_ACCOUNTS_GROUP, "text"), //
new AgentField("agentOpeningBalance", "Opening Balance", AGENT_BALANCES_GROUP, "money"));
/**
* An agent request laid out for the checker: the client, the branch and the ledger account by name
* rather than by number, in the sections the screen has. Only what the request carries is shown, so
* an edit lists just the fields it changes.
*/
private List<Map<String, Object>> agentFieldsFor(final JsonObject json) {
final List<Map<String, Object>> fields = new ArrayList<>();
for (final AgentField field : AGENT_FIELDS) {
if (!json.has(field.parameter()) || json.get(field.parameter()).isJsonNull()) {
continue;
}
final String raw = json.get(field.parameter()).getAsString();
// A new agent starts with nothing, so a zero opening balance is not something to approve and
// is left out. One that is not zero - an edit that sets it - is shown.
if ("money".equals(field.kind()) && isZero(raw)) {
continue;
}
final String value = switch (field.kind()) {
case "client" -> clientLabel(raw);
case "office" -> nameOr(first("select name from m_office where id = ?", parseLong(raw)), "Branch #" + raw);
case "gl" -> nameOr(first("select gl_code || ' · ' || name from acc_gl_account where id = ?", parseLong(raw)),
"Account #" + raw);
case "money" -> "₹" + plain(raw);
default -> raw.trim();
};
final Map<String, Object> out = new LinkedHashMap<>();
out.put("key", field.parameter());
out.put("label", field.label());
out.put("group", field.group());
out.put("format", "text");
out.put("value", value);
fields.add(out);
}
return fields;
}
/** The guarantors an agent request names, each with the client they are by number and name. */
private List<Map<String, Object>> guarantorsOf(final JsonObject json) {
final List<Map<String, Object>> guarantors = new ArrayList<>();
if (!json.has("guarantors") || !json.get("guarantors").isJsonArray()) {
return guarantors;
}
for (final JsonElement element : json.getAsJsonArray("guarantors")) {
if (!element.isJsonObject()) {
continue;
}
final JsonObject g = element.getAsJsonObject();
final Map<String, Object> out = new LinkedHashMap<>();
out.put("client", text(g, "clientId") == null ? null : clientLabel(text(g, "clientId")));
out.put("name", text(g, "guarantorName"));
out.put("telephone", text(g, "telephone"));
out.put("address", joined(", ", text(g, "address1"), text(g, "address2"), text(g, "city"), text(g, "pinCode")));
guarantors.add(out);
}
return guarantors;
}
/**
* An agent's code, name, client, branch, savings account and guarantor count. An agent already on
* the books is read as they stand; one still being opened does not exist yet, so is read from the
* request and has no code until approval gives it one.
*/
private Map<String, Object> agentColumns(final Long agentId, final JsonObject json) {
final Map<String, Object> columns = new LinkedHashMap<>();
if (agentId != null) {
columns.putAll(this.jdbcTemplate.queryForList( // NOSONAR
"select a.agent_code as \"agentCode\", a.agent_name as \"agentName\", c.display_name as \"clientName\", "
+ "o.name as \"officeName\", a.agent_saving_account_no as \"savingAccountNo\", "
+ "(select count(*) from m_dds_agent_guarantor g where g.agent_id = a.id) as \"guarantorCount\" "
+ "from m_dds_agent a left join m_client c on c.id = a.client_id left join m_office o on o.id = a.office_id "
+ "where a.id = ?",
agentId).stream().findFirst().orElse(Map.of()));
return columns;
}
final String clientId = text(json, "clientId");
columns.put("agentCode", null);
columns.put("agentName", text(json, "agentNameId"));
columns.put("clientName", clientId == null ? null : first("select display_name from m_client where id = ?", parseLong(clientId)));
final String officeId = text(json, "officeId");
columns.put("officeName", officeId == null ? null : first("select name from m_office where id = ?", parseLong(officeId)));
columns.put("savingAccountNo", text(json, "agentSavingAccountNo"));
columns.put("guarantorCount", json.has("guarantors") && json.get("guarantors").isJsonArray() ? json.getAsJsonArray("guarantors").size() : 0);
return columns;
}
private String clientLabel(final String clientId) {
final String name = first("select display_name from m_client where id = ?", parseLong(clientId));
return name == null ? "Client #" + clientId : clientId + " · " + name;
}
/** The name an agent request gives, or the client it is for when none was typed. */
private String agentNameOf(final JsonObject json) {
final String name = text(json, "agentNameId");
if (name != null && !name.isBlank()) {
return name.trim();
}
final String clientId = text(json, "clientId");
return clientId == null ? "" : clientLabel(clientId);
}
private static boolean isZero(final String amount) {
try {
return new BigDecimal(amount.trim()).signum() == 0;
} catch (NumberFormatException e) {
return false;
}
}
private static String nameOr(final String found, final String fallback) {
return found == null ? fallback : found;
}
private static String joined(final String separator, final String... parts) {
final List<String> present = new ArrayList<>();
for (final String part : parts) {
if (part != null && !part.isBlank()) {
present.add(part.trim());
}
}
return String.join(separator, present);
}
/** A number an agent request carried as text, or -1 - which no row has - when it was not one. */
private static Long parseLong(final String value) {
try {
return Long.valueOf(value.trim());
} catch (NumberFormatException e) {
return -1L;
}
}
/**
* A DDS product's terms, in the order the Account Type screen asks for them: the column it is
* stored in, the parameter the request carries it as, its label, how it reads, and the section of
* the review it is shown in.
*/
private record ProductField(String column, String parameter, String label, String format, String group) {}
private static final String GROUP_PRODUCT = "Product";
private static final String GROUP_INTEREST = "Interest";
private static final String GROUP_CLOSURE = "Maturity & Premature Closure";
private static final String GROUP_ACCOUNTING = "Accounting";
private static final List<ProductField> PRODUCT_FIELDS = List.of( //
new ProductField("account_type_group", "accountTypeGroup", "Account Type Group", "text", GROUP_PRODUCT), //
new ProductField("account_type_code", "accountTypeCode", "Account Type Code", "text", GROUP_PRODUCT), //
new ProductField("account_type_description", "accountTypeDescription", "Description", "text", GROUP_PRODUCT), //
new ProductField("is_active", "active", "Active", "yesno", GROUP_PRODUCT), //
new ProductField("interest_rate", "interestRate", "Interest Rate", "percent", GROUP_INTEREST), //
new ProductField("auto_interest_post", "autoInterestPost", "Auto Interest Post", "yesno", GROUP_INTEREST), //
new ProductField("auto_interest_post_when", "autoInterestPostWhen", "Auto Interest Post When", "text", GROUP_INTEREST), //
new ProductField("maturity_auto_close", "maturityAutoClose", "Maturity Auto Close", "yesno", GROUP_CLOSURE), //
new ProductField("premature_lockin_months", "prematureLockinMonths", "Premature Lock-in", "months", GROUP_CLOSURE), //
new ProductField("premature_interest_reduction", "prematureInterestReduction", "Premature Interest Reduction", "percent",
GROUP_CLOSURE), //
new ProductField("premature_agent_commission_penalty", "prematureAgentCommissionPenalty", "Agent Commission Penalty",
"percent", GROUP_CLOSURE), //
new ProductField("deposit_liability_gl_id", "depositLiabilityGlId", "DDS Customer Deposits (Liability)", "gl",
GROUP_ACCOUNTING), //
new ProductField("interest_expense_gl_id", "interestExpenseGlId", "Interest Expense", "gl", GROUP_ACCOUNTING), //
new ProductField("commission_expense_gl_id", "commissionExpenseGlId", "Agent Commission Expense", "gl", GROUP_ACCOUNTING));
/**
* What an entry that moves no money actually asks for, field by field, so the checker can read the
* terms they are approving rather than only a code.
*
* An edit still waiting is shown against the product as it stands, with each changed field marked,
* because what matters there is what will change. Once actioned, the product has already moved on,
* so only the values the request carried are shown.
*/
private List<Map<String, Object>> fieldsFor(final Map<String, Object> row, final Map<String, Object> entry) {
if (isAgentRequest(row)) {
return agentFieldsFor(jsonOf(entry.get("json")));
}
if (isPartyRequest(row)) {
return partyFieldsFor(jsonOf(entry.get("json")));
}
if (!"DDSACCOUNTTYPE".equals(row.get("entity"))) {
return List.of();
}
final JsonObject json = jsonOf(entry.get("json"));
final Long resourceId = (Long) row.get("resourceId");
final boolean compare = "UPDATE".equals(row.get("action")) && "PENDING".equals(row.get("status")) && resourceId != null;
final Map<String, Object> current = compare
? this.jdbcTemplate.queryForList("select * from m_dds_account_type where id = ?", resourceId).stream().findFirst() // NOSONAR
.orElse(Map.of())
: Map.of();
final List<Map<String, Object>> fields = new ArrayList<>();
for (final ProductField field : PRODUCT_FIELDS) {
final boolean requested = json.has(field.parameter()) && !json.get(field.parameter()).isJsonNull();
final String proposed = requested ? display(json.get(field.parameter()).getAsString(), field.format()) : null;
final Map<String, Object> out = new LinkedHashMap<>();
out.put("key", field.parameter());
out.put("label", field.label());
out.put("group", field.group());
out.put("format", field.format());
if (compare) {
final Object stored = current.get(field.column());
final String was = stored == null ? null : display(String.valueOf(stored), field.format());
final String now = requested ? proposed : was;
out.put("current", was);
out.put("value", now);
out.put("changed", requested && !Objects.equals(was, now));
} else if (requested) {
out.put("value", proposed);
} else {
continue;
}
fields.add(out);
}
return fields;
}
/** As format, and a ledger account id read as the account itself: "DDS CUSTOMER DEPOSITS (200601)". */
private String display(final String value, final String format) {
if (!"gl".equals(format) || value == null || value.isBlank()) {
return format(value, format);
}
final Map<String, Object> gl = this.jdbcTemplate.queryForList("select gl_code, name from acc_gl_account where id = ?", // NOSONAR
parseLong(value)).stream().findFirst().orElse(Map.of());
return gl.isEmpty() ? "GL " + value.trim() + " (not found)" : gl.get("name") + " (" + gl.get("gl_code") + ")";
}
/** A stored or requested value as the screen shows it: Yes/No, 2%, 12 months. */
private static String format(final String value, final String format) {
if (value == null || value.isBlank()) {
return null;
}
return switch (format) {
case "yesno" -> Boolean.parseBoolean(value.trim()) ? "Yes" : "No";
case "percent" -> plain(value) + "%";
case "months" -> plain(value) + " month" + ("1".equals(plain(value)) ? "" : "s");
default -> value.trim();
};
}
/** 2.000000 reads as 2, and 1.50 as 1.5, so a stored rate and a typed one compare equal. */
private static String plain(final String value) {
try {
return new BigDecimal(value.trim()).stripTrailingZeros().toPlainString();
} catch (NumberFormatException e) {
return value.trim();
}
}
/** /**
* The debit and credit sides of the entry, as its voucher shows them. Once the money has moved they * The debit and credit sides of the entry, as its voucher shows them. Once the money has moved they
* are the voucher's own lines; while it waits they are what approving it will move, worked out from * are the voucher's own lines; while it waits they are what approving it will move, worked out from
@@ -160,13 +578,40 @@ public class DdsApprovalReadService {
} }
} }
case "DDSBATCH:POST" -> { case "DDSBATCH:POST" -> {
final String expense = first("select c.name from m_dds_batch_run r join c_configuration c on c.name = " // The expense side, on the accounts of the DDS products concerned - interest on each
+ "case when r.job_type like 'COMMISSION%' then 'dds-agent-commission-expense-account' " // customer's product's interest account, commission shared over the commission accounts
+ "else 'dds-interest-expense-account' end where r.id = ?", resourceId); // of the products each agent collected for, as the posting itself will book it.
final Map<String, Object> gl = expense == null ? Map.of() final String jobType = first("select job_type from m_dds_batch_run where id = ?", resourceId);
: this.jdbcTemplate.queryForList("select g.gl_code as \"code\", g.name from c_configuration c " // NOSONAR if (jobType != null && jobType.startsWith("COMMISSION")) {
+ "join acc_gl_account g on g.id = c.value where c.name = ?", expense).stream().findFirst().orElse(Map.of()); final Map<String, BigDecimal> byAccount = new LinkedHashMap<>();
lines.add(line("DR", (String) gl.get("code"), (String) gl.get("name"), "GL", amount)); final Map<String, String> names = new LinkedHashMap<>();
for (final Map<String, Object> runRow : this.jdbcTemplate.queryForList( // NOSONAR
"select agent_id, amount from m_dds_batch_run_row where batch_run_id = ? and amount > 0 order by id", resourceId)) {
try {
this.ddsLedger.commissionSplit(((Number) runRow.get("agent_id")).longValue(), (BigDecimal) runRow.get("amount"), resourceId)
.forEach((account, share) -> {
byAccount.merge(account.getGlCode(), share, BigDecimal::add);
names.put(account.getGlCode(), account.getName());
});
} catch (final DdsLedgerNotConfiguredException e) {
byAccount.merge("", (BigDecimal) runRow.get("amount"), BigDecimal::add);
names.put("", "Agent Commission Expense not set on the DDS product");
}
}
byAccount.forEach((code, share) -> lines.add(line("DR", code.isEmpty() ? null : code, names.get(code), "GL", share)));
} else {
for (final Map<String, Object> gl : this.jdbcTemplate.queryForList( // NOSONAR
"select g.gl_code as \"code\", coalesce(g.name, 'Interest Expense not set on DDS product ' || "
+ "upper(trim(p.sub_type))) as \"name\", sum(r.amount) as \"amount\" from m_dds_batch_run_row r "
+ "join m_dds_party p on p.id = r.party_id "
+ "left join m_dds_account_type t on t.account_type_code = upper(trim(p.sub_type)) "
+ "left join acc_gl_account g on g.id = t.interest_expense_gl_id "
+ "where r.batch_run_id = ? and r.amount > 0 group by g.gl_code, g.name, upper(trim(p.sub_type)) "
+ "order by g.gl_code",
resourceId)) {
lines.add(line("DR", (String) gl.get("code"), (String) gl.get("name"), "GL", (BigDecimal) gl.get("amount")));
}
}
for (final Map<String, Object> credited : this.jdbcTemplate.queryForList( // NOSONAR for (final Map<String, Object> credited : this.jdbcTemplate.queryForList( // NOSONAR
"select coalesce(p.account_no, a.agent_saving_account_no) as \"accountNo\", " "select coalesce(p.account_no, a.agent_saving_account_no) as \"accountNo\", "
+ "coalesce(p.party_code || ' · ' || p.party_name, a.agent_code || ' · ' || a.agent_name) as \"accountName\", " + "coalesce(p.party_code || ' · ' || p.party_name, a.agent_code || ' · ' || a.agent_name) as \"accountName\", "
@@ -314,11 +759,15 @@ public class DdsApprovalReadService {
case "CLOSE" -> "Close Agent"; case "CLOSE" -> "Close Agent";
default -> "Edit Agent"; default -> "Edit Agent";
}; };
details = resourceId != null ? agent(resourceId) : String.valueOf(text(json, "agentName")); details = resourceId != null ? agent(resourceId) : agentNameOf(json);
} }
case "DDSACCOUNTTYPE:CREATE", "DDSACCOUNTTYPE:UPDATE" -> { case "DDSACCOUNTTYPE:CREATE", "DDSACCOUNTTYPE:UPDATE" -> {
type = "CREATE".equals(action) ? "New DDS Product" : "Edit DDS Product"; type = "CREATE".equals(action) ? "New DDS Product" : "Edit DDS Product";
details = text(json, "accountTypeCode") != null ? text(json, "accountTypeCode") : scheme(resourceId); details = text(json, "accountTypeCode") != null ? text(json, "accountTypeCode") : scheme(resourceId);
final String description = text(json, "accountTypeDescription");
if (description != null && !description.isBlank()) {
details = details == null || details.isBlank() ? description : details + " · " + description;
}
} }
case "COMMISSIONSLAB:CREATE", "COMMISSIONSLAB:UPDATE" -> { case "COMMISSIONSLAB:CREATE", "COMMISSIONSLAB:UPDATE" -> {
type = "CREATE".equals(action) ? "New Commission Slab" : "Edit Commission Slab"; type = "CREATE".equals(action) ? "New Commission Slab" : "Edit Commission Slab";
@@ -342,6 +791,14 @@ public class DdsApprovalReadService {
row.put("type", type); row.put("type", type);
row.put("details", details); row.put("details", details);
row.put("amount", amount); row.put("amount", amount);
if ("PARTY".equals(entity) && ("CREATE".equals(action) || "UPDATE".equals(action))) {
// The account in the columns Approvals -> Pending Account lists every other account by.
row.putAll(accountColumns(resourceId, json));
}
if ("AGENT".equals(entity)) {
// The agent in the columns Approvals -> Agent Approval lists them by.
row.putAll(agentColumns(resourceId, json));
}
final Long commandId = ((Number) entry.get("id")).longValue(); final Long commandId = ((Number) entry.get("id")).longValue();
final String voucherNo = switch (status) { final String voucherNo = switch (status) {
case STATUS_PROCESSED -> voucherFor(commandId, entity, action, resourceId, json, (String) entry.get("madeOn")); case STATUS_PROCESSED -> voucherFor(commandId, entity, action, resourceId, json, (String) entry.get("madeOn"));
@@ -428,6 +885,37 @@ public class DdsApprovalReadService {
return found == null ? "Agent #" + agentId : found.trim(); return found == null ? "Agent #" + agentId : found.trim();
} }
/**
* A DDS account's number, holder, scheme, branch and agent. An edit reads the account as it
* stands; a new account does not exist until approved, so it is read from the request, and has
* no number yet.
*/
private Map<String, Object> accountColumns(final Long partyId, final JsonObject json) {
final Map<String, Object> columns = new LinkedHashMap<>();
if (partyId != null) {
final Map<String, Object> stored = this.jdbcTemplate.queryForList( // NOSONAR
"select p.account_no as \"accountNo\", p.party_name as \"clientName\", p.sub_type as \"subType\", "
+ "o.name as \"officeName\", trim(a.agent_code || ' ' || coalesce(a.agent_name, '')) as \"agentName\" "
+ "from m_dds_party p left join m_office o on o.id = p.office_id "
+ "left join m_dds_agent a on a.id = p.agent_id where p.id = ?",
partyId).stream().findFirst().orElse(Map.of());
columns.putAll(stored);
} else {
columns.put("accountNo", null);
columns.put("clientName", text(json, "partyName"));
columns.put("subType", text(json, "subType"));
final Long officeId = longOf(json, "officeId");
columns.put("officeName", officeId == null ? null : first("select name from m_office where id = ?", officeId));
final Long agentId = longOf(json, "agentId");
columns.put("agentName", agentId == null ? null : agent(agentId));
}
final String subType = (String) columns.remove("subType");
final String description = subType == null ? null
: first("select account_type_description from m_dds_account_type where account_type_code = ?", subType);
columns.put("productName", description == null ? subType : subType + " · " + description);
return columns;
}
private String scheme(final Long accountTypeId) { private String scheme(final Long accountTypeId) {
return accountTypeId == null ? "" : first("select account_type_code from m_dds_account_type where id = ?", accountTypeId); return accountTypeId == null ? "" : first("select account_type_code from m_dds_account_type where id = ?", accountTypeId);
} }
@@ -22,6 +22,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST; import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
@@ -38,6 +39,8 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.dds.batch.data.BatchRunData; import org.apache.fineract.portfolio.dds.batch.data.BatchRunData;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformService; import org.apache.fineract.portfolio.dds.batch.service.BatchRunReadPlatformService;
import org.apache.fineract.portfolio.dds.batch.service.BatchRunWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Path("/v1/dds/batchruns") @Path("/v1/dds/batchruns")
@@ -52,6 +55,20 @@ public class BatchRunsApiResource {
private final BatchRunReadPlatformService readPlatformService; private final BatchRunReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final DdsVoucherService ddsVoucherService; private final DdsVoucherService ddsVoucherService;
private final BatchRunWritePlatformService writePlatformService;
@DELETE
@Path("{batchRunId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Discard a previewed DDS Batch Run", description = "Deletes a run that was generated but never posted - "
+ "the preview the screen's Discard button throws away. Refused for a run that is posted, posting, failed, or whose "
+ "posting is waiting for approval.")
public CommandProcessingResult discard(@PathParam("batchRunId") @Parameter(description = "batchRunId") final Long batchRunId) {
// Whoever may generate a preview may throw one away; it moved nothing.
this.context.authenticatedUser().validateHasPermissionTo("GENERATE_" + RESOURCE_NAME_FOR_PERMISSIONS);
this.writePlatformService.discardBatchRun(batchRunId);
return new CommandProcessingResultBuilder().withEntityId(batchRunId).build();
}
@GET @GET
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@@ -30,6 +30,12 @@ public interface BatchRunWritePlatformService {
CommandProcessingResult postBatchRun(Long batchRunId, JsonCommand command); CommandProcessingResult postBatchRun(Long batchRunId, JsonCommand command);
/**
* Throws away a run that was only previewed - generated and never posted, nor waiting for a
* checker to approve its posting. Nothing was applied by generating it, so nothing is undone.
*/
void discardBatchRun(Long batchRunId);
/** /**
* Posts interest for every scheme whose Account Type says to do it automatically and whose posting * Posts interest for every scheme whose Account Type says to do it automatically and whose posting
* day falls on the given date. Driven by the Post DDS Interest scheduled job. * day falls on the given date. Driven by the Post DDS Interest scheduled job.
@@ -29,6 +29,8 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
@@ -83,6 +85,7 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
* re-running it for the same period finds no days left; a posting takes its amount off the parked * re-running it for the same period finds no days left; a posting takes its amount off the parked
* figure, so re-posting finds nothing left to move. * figure, so re-posting finds nothing left to move.
*/ */
@Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWritePlatformService { public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWritePlatformService {
@@ -141,9 +144,13 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
private static final String COMMISSION_ACCOUNT_SQL = "select a.id, a.status_enum, a.product_id, p.deposit_type_enum " private static final String COMMISSION_ACCOUNT_SQL = "select a.id, a.status_enum, a.product_id, p.deposit_type_enum "
+ "from m_savings_account a join m_savings_product p on p.id = a.product_id where a.account_no = ?"; + "from m_savings_account a join m_savings_product p on p.id = a.product_id where a.account_no = ?";
/** The product DDS opens customers' scheme accounts under - see 0492. */ /**
private static final String DDS_DEPOSIT_PRODUCT_SQL = "select value from c_configuration " * Whether a savings account is a DDS customer's scheme account - one a DDS party is linked to.
+ "where name = 'dds-deposit-product' and enabled = true"; * Read off the parties themselves rather than a configured product id: what makes an account a
* scheme account is that a scheme runs on it, whatever product it was opened under.
*/
private static final String DDS_SCHEME_ACCOUNT_SQL = "select count(*) from m_dds_party p "
+ "join m_savings_account a on a.id = p.savings_account_id where a.account_no = ?";
private static final int SAVINGS_DEPOSIT_TYPE = 100; private static final int SAVINGS_DEPOSIT_TYPE = 100;
private static final int FIXED_DEPOSIT_TYPE = 200; private static final int FIXED_DEPOSIT_TYPE = 200;
@@ -189,6 +196,9 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType); default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
} }
// A new preview of the same job replaces the one before it: generating twice used to leave two
// identical "not posted" runs on the list, and nobody could tell which one to post.
discardSupersededPreviews(jobType, subType);
this.batchRunRepository.saveAndFlush(batchRun); this.batchRunRepository.saveAndFlush(batchRun);
return new CommandProcessingResultBuilder() // return new CommandProcessingResultBuilder() //
@@ -197,6 +207,35 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
.build(); .build();
} }
/**
* A run that is only a preview: calculated, never posted, not posting or failed, and with no
* posting of it waiting for a checker (a held posting names the run, and approving it later must
* find it). Only such a run is ever deleted - generating applies nothing, so nothing is lost.
*/
private static final String PREVIEW_ONLY = "r.status = 'CALCULATED' and not exists (select 1 from m_portfolio_command_source c "
+ "where c.entity_name = 'DDSBATCH' and c.resource_id = r.id and c.status = 2)";
private void discardSupersededPreviews(final String jobType, final String subType) {
final String sameJob = "r.job_type = ? and coalesce(r.sub_type, '') = coalesce(?, '') and " + PREVIEW_ONLY;
this.jdbcTemplate.update("delete from m_dds_batch_run_row where batch_run_id in (select r.id from m_dds_batch_run r where " // NOSONAR
+ sameJob + ")", jobType, subType);
this.jdbcTemplate.update("delete from m_dds_batch_run r where " + sameJob, jobType, subType); // NOSONAR
}
@Transactional
@Override
public void discardBatchRun(final Long batchRunId) {
final List<Long> found = this.jdbcTemplate.queryForList("select r.id from m_dds_batch_run r where r.id = ? and " + PREVIEW_ONLY, // NOSONAR
Long.class, batchRunId);
if (found.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.dds.batch.run.not.discardable",
"Batch run #" + batchRunId + " cannot be discarded: only a run that was generated and not posted (or sent for"
+ " posting approval) can be.");
}
this.jdbcTemplate.update("delete from m_dds_batch_run_row where batch_run_id = ?", batchRunId); // NOSONAR
this.jdbcTemplate.update("delete from m_dds_batch_run where id = ?", batchRunId); // NOSONAR
}
/** /**
* Parses the "Branch" checklist selection - null/empty means no filter ("All"). Matched against * 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 * Agent.homeBranch by name, since that field is plain text (not a real Office FK) mirroring the
@@ -596,26 +635,47 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
final String jobType = batchRun.getJobType(); final String jobType = batchRun.getJobType();
final LocalDate jobDate = batchRun.getJobDate(); final LocalDate jobDate = batchRun.getJobDate();
// Only rows that actually moved money are counted, and their sum kept beside the run's total: a
// posting row is passed over when another run has already paid what it lists, and the run must
// not read as though it paid it again.
int done = 0; int done = 0;
BigDecimal moved = BigDecimal.ZERO;
for (final BatchRunRow row : batchRun.getRows()) { for (final BatchRunRow row : batchRun.getRows()) {
switch (jobType) { final boolean applied = switch (jobType) {
case INTEREST_CALCULATION -> accrue(row, jobDate, false); case INTEREST_CALCULATION -> {
case OD_INTEREST_CALCULATION -> accrue(row, jobDate, true); accrue(row, jobDate, false);
yield true;
}
case OD_INTEREST_CALCULATION -> {
accrue(row, jobDate, true);
yield true;
}
case INTEREST_POSTING -> postInterest(row, batchRun, false); case INTEREST_POSTING -> postInterest(row, batchRun, false);
case OD_INTEREST_POSTING -> postInterest(row, batchRun, true); case OD_INTEREST_POSTING -> postInterest(row, batchRun, true);
case COMMISSION_CALCULATION -> accrueCommission(row, batchRun); case COMMISSION_CALCULATION -> {
case COMMISSION_POSTING -> payCommission(row, batchRun); accrueCommission(row, batchRun);
case MATURITY_AUTO_CLOSE -> closeAtMaturity(row); yield true;
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
} }
case COMMISSION_POSTING -> payCommission(row, batchRun);
case MATURITY_AUTO_CLOSE -> {
closeAtMaturity(row);
yield true;
}
default -> throw new IllegalStateException("Unsupported DDS batch job type: " + jobType);
};
if (applied) {
done++; done++;
moved = moved.add(row.getAmount() == null ? BigDecimal.ZERO : row.getAmount());
} else {
log.warn("DDS batch run #{}: row {} passed over - what it lists was already paid", batchRun.getId(), row.getId());
}
} }
// Only at the end. A crediting run is one transaction from start to finish, so a count written // Only at the end. A crediting run is one transaction from start to finish, so a count written
// part-way through would be invisible outside it until the run commits - and writing it from a // part-way through would be invisible outside it until the run commits - and writing it from a
// transaction of its own would have two of them contending for the same row. The status says // transaction of its own would have two of them contending for the same row. The status says
// POSTING while it works, which is what a clerk actually needs to know. // POSTING while it works, which is what a clerk actually needs to know.
batchRun.recordProgress(done); batchRun.recordPosted(done, moved);
} }
/** /**
@@ -662,10 +722,10 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
* same accrued interest would each list it in full, and paying both would credit interest that * same accrued interest would each list it in full, and paying both would credit interest that
* was never earned. * was never earned.
*/ */
private void postInterest(final BatchRunRow row, final BatchRun batchRun, final boolean odInterest) { private boolean postInterest(final BatchRunRow row, final BatchRun batchRun, final boolean odInterest) {
final Party party = partyOf(row.getPartyId()); final Party party = partyOf(row.getPartyId());
if (party.accruedInterest(odInterest).compareTo(row.getAmount()) < 0) { if (party.accruedInterest(odInterest).compareTo(row.getAmount()) < 0) {
return; return false;
} }
final BigDecimal newBalance = party.postInterest(row.getAmount(), odInterest); final BigDecimal newBalance = party.postInterest(row.getAmount(), odInterest);
@@ -678,6 +738,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
final String transactionType = odInterest ? "OD_INTEREST_POST" : "INTEREST_POST"; final String transactionType = odInterest ? "OD_INTEREST_POST" : "INTEREST_POST";
this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), batchRun.getJobDate(), transactionType, this.partyTransactionRepository.save(new PartyTransaction(party, party.getAgent(), batchRun.getJobDate(), transactionType,
DIRECTION_CREDIT, null, null, null, row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(), newBalance)); DIRECTION_CREDIT, null, null, null, row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(), newBalance));
return true;
} }
/** /**
@@ -716,12 +777,12 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
* customers and not yet handed to a branch, which commission has nothing to do with. The entry is * customers and not yet handed to a branch, which commission has nothing to do with. The entry is
* recorded against the balance as it stands, so the ledger reads consistently. * recorded against the balance as it stands, so the ledger reads consistently.
*/ */
private void payCommission(final BatchRunRow row, final BatchRun batchRun) { private boolean payCommission(final BatchRunRow row, final BatchRun batchRun) {
final Agent agent = agentOf(row.getAgentId()); final Agent agent = agentOf(row.getAgentId());
// Only what is actually owed can be paid: two posting runs generated over the same owed // Only what is actually owed can be paid: two posting runs generated over the same owed
// figure would each list it in full, and paying both would pay commission twice. // figure would each list it in full, and paying both would pay commission twice.
if (agent.getCommission().compareTo(row.getAmount()) < 0) { if (agent.getCommission().compareTo(row.getAmount()) < 0) {
return; return false;
} }
agent.payOutCommission(row.getAmount()); agent.payOutCommission(row.getAmount());
@@ -730,6 +791,7 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
this.partyTransactionRepository.save(new PartyTransaction(null, agent, batchRun.getJobDate(), "COMMISSION_POST", DIRECTION_CREDIT, this.partyTransactionRepository.save(new PartyTransaction(null, agent, batchRun.getJobDate(), "COMMISSION_POST", DIRECTION_CREDIT,
null, null, agent.getAgentSavingAccountNo(), row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(), null, null, agent.getAgentSavingAccountNo(), row.getAmount(), null, null, "DDS batch run #" + batchRun.getId(),
agent.getAgentCurrentBalance())); agent.getAgentCurrentBalance()));
return true;
} }
/** /**
@@ -773,8 +835,8 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
+ " Set an ordinary savings account for this agent"); + " Set an ordinary savings account for this agent");
} }
final List<Long> ddsProduct = this.jdbcTemplate.queryForList(DDS_DEPOSIT_PRODUCT_SQL, Long.class); // NOSONAR final Integer schemes = this.jdbcTemplate.queryForObject(DDS_SCHEME_ACCOUNT_SQL, Integer.class, accountNo); // NOSONAR
if (!ddsProduct.isEmpty() && productId.equals(ddsProduct.get(0))) { if (schemes != null && schemes > 0) {
throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), "account " + accountNo throw new AgentCommissionAccountUnusableException(agent.getAgentCode(), "account " + accountNo
+ " is a DDS scheme account belonging to a customer, so commission paid into it would be counted as their" + " is a DDS scheme account belonging to a customer, so commission paid into it would be counted as their"
+ " deposits. Set an ordinary savings account for this agent"); + " deposits. Set an ordinary savings account for this agent");
@@ -787,8 +849,12 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
"account " + accountNo + " is " + statusName(status) + " rather than active, so it cannot take a deposit yet"); "account " + accountNo + " is " + statusName(status) + " rather than active, so it cannot take a deposit yet");
} }
// Each product the agent collected for books its share on its own commission account; the
// collections the share was worked out on are then marked as paid for.
final Map<GLAccount, BigDecimal> shares = this.ddsLedger.commissionSplit(agent.getId(), amount, batchRun.getId());
this.ddsLedger.payCommission(((Number) account.get("id")).longValue(), batchRun.getJobDate(), amount, this.ddsLedger.payCommission(((Number) account.get("id")).longValue(), batchRun.getJobDate(), amount,
"DDS commission, batch run #" + batchRun.getId()); "DDS commission, batch run #" + batchRun.getId(), shares);
this.ddsLedger.markCommissionPaid(agent.getId(), batchRun.getId());
} }
/** Fineract's savings statuses, named the way the screens name them. */ /** Fineract's savings statuses, named the way the screens name them. */
@@ -810,28 +876,36 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
/** /**
* Posts interest for every scheme that is due today. * Posts interest for every scheme whose posting period ended yesterday.
* *
* A scheme that posts automatically has decided it needs no review, so this does both halves in * A scheme that posts automatically has decided it needs no review, so this does both halves in
* one go: it calculates the interest since the last run and then posts it into the customers' * one go: it calculates the interest since the last run and then posts it into the customers'
* balances. Both halves go through the same code the screen's Post button uses, and both leave a * balances. Both halves go through the same code the screen's Post button uses, and both leave a
* run behind, so an automatic posting is as visible and as auditable as a manual one. * run behind, so an automatic posting is as visible and as auditable as a manual one.
* *
* Runs are scoped to one scheme at a time, because the posting day belongs to the scheme. * Interest posts at the end of the period - the last day of the month, quarter, half-year or
* year - as it does on savings and fixed deposits, so there is no posting day to configure and
* no month whose length can trip one up. The job runs just after midnight, so it looks back at
* the day that has just closed: posting on the 1st through the last day of the previous month
* takes in everything collected on that last day, which a run at 00:20 on the day itself would
* not yet have seen.
*
* Runs are scoped to one scheme at a time, because the posting frequency belongs to the scheme.
*/ */
@Transactional @Transactional
@Override @Override
public int postDueAutoInterest(final LocalDate businessDate) { public int postDueAutoInterest(final LocalDate businessDate) {
final LocalDate periodEnd = businessDate.minusDays(1);
int posted = 0; int posted = 0;
for (final DdsAccountType accountType : this.ddsAccountTypeRepository.findDueForAutoInterestPost()) { for (final DdsAccountType accountType : this.ddsAccountTypeRepository.findDueForAutoInterestPost()) {
if (!isDueOn(accountType, businessDate)) { if (!isPeriodEnd(accountType, periodEnd)) {
continue; continue;
} }
final String subType = accountType.getAccountTypeCode(); final String subType = accountType.getAccountTypeCode();
calculateAndPost(INTEREST_CALCULATION, businessDate, subType); calculateAndPost(INTEREST_CALCULATION, periodEnd, subType);
calculateAndPost(INTEREST_POSTING, businessDate, subType); calculateAndPost(INTEREST_POSTING, periodEnd, subType);
posted++; posted++;
} }
@@ -839,19 +913,11 @@ public class BatchRunWritePlatformServiceJpaRepositoryImpl implements BatchRunWr
} }
/** /**
* Whether this scheme's posting day falls on the given date. * Whether the given date closes one of this scheme's posting periods: the last day of a month
* * the scheme's frequency posts in. February's end is the 28th or 29th, whichever the year has.
* A day beyond the length of the month means the end of that month: a scheme set to 31 posts on
* 31 January, 28 February and 30 April. Without that, February would simply be skipped and the
* account would lose a month's interest every year.
*/ */
private boolean isDueOn(final DdsAccountType accountType, final LocalDate date) { private boolean isPeriodEnd(final DdsAccountType accountType, final LocalDate date) {
if (!monthIsDue(accountType.getAutoInterestPostWhen(), date)) { return monthIsDue(accountType.getAutoInterestPostWhen(), date) && date.getDayOfMonth() == date.lengthOfMonth();
return false;
}
final int configuredDay = accountType.getAutoInterestPostDay();
final int effectiveDay = Math.min(configuredDay, date.lengthOfMonth());
return date.getDayOfMonth() == effectiveDay;
} }
/** /**
@@ -115,6 +115,7 @@ public final class CollectionEntryCommandFromApiJsonDeserializer {
.inMinMaxRange(1, DdsSchedule.CATCH_UP_DAYS); .inMinMaxRange(1, DdsSchedule.CATCH_UP_DAYS);
} else { } else {
v.reset().parameter(ENTRIES).parameterAtIndexArray("amount", i).value(decimalOf(row)).notNull().positiveAmount(); v.reset().parameter(ENTRIES).parameterAtIndexArray("amount", i).value(decimalOf(row)).notNull().positiveAmount();
toThePaisa(v, decimalOf(row));
} }
final String mode = stringOf(row, "transactionMode"); final String mode = stringOf(row, "transactionMode");
@@ -201,4 +202,14 @@ public final class CollectionEntryCommandFromApiJsonDeserializer {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors); throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
} }
} }
/**
* Money is kept to the paisa, as a savings account keeps it: 100.555 is refused rather than stored
* on the passbook and the journal as typed.
*/
private static void toThePaisa(final DataValidatorBuilder v, final BigDecimal amount) {
if (amount != null && amount.stripTrailingZeros().scale() > 2) {
v.failWithCodeAndMessage("more.than.two.decimals", "An amount can have at most two decimal places (paise).", amount);
}
}
} }

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