Compare commits

...
57 Commits
Author SHA1 Message Date
keshav c91dcf4d99 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-10-12 10:23:26 +05:30
keshav 2d2eac8d03 feat: recurring deposit enhancements, dashboard, chart, and account view updates 2026-10-12 10:23:10 +05:30
nitesh 0976bc230c Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest 2026-10-07 10:19:26 +05:30
keshav 6b0f218b70 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest 2026-10-07 10:13:37 +05:30
keshav 6226682b36 feat: updates for cashier, tds reports, rbi reserve and dashboard 2026-10-07 10:13:28 +05:30
niteshandClaude Opus 5 38884610d2 feat(shares): preview the approval checklist before submit, fix settlement GL option
The Clerk Dashboard's standalone Share Withdrawal Request form never offered a
GL settlement destination -- it always submitted SAVINGS, silently, while the
Cashier's Transaction Entry screen already let a clerk pick any postable GL
account on its CR row. Added the same "Settlement Destination" choice (Savings
/ GL Account) with a searchable GL selector here too, so both entry points to
the same backend endpoint behave the same way.

Both share-withdrawal confirm dialogs (savings settlement and GL settlement, in
transaction-entry.component.ts) now call the backend's new preview-validate
endpoint before showing the confirm dialog, so a clerk sees the same checklist
a manager will see on approval -- a missing GL mapping, an unverified KYC, an
open AML alert -- before committing to the request, not after a manager has
already spent time reviewing it. A failing check is shown, not blocked: real
hard rules are still enforced server-side at approve(). Guarded with a 5s
timeout and a fallback message so a slow or unavailable check can never leave
the dialog looking stuck.

error-handler.interceptor.ts: a document/signature attachment whose row
survives but whose file is missing from the content store now resolves to a
silent 404 (matching the existing image-fetch handling) instead of surfacing a
raw error. Background GET failures (500/501/503) on calls a component already
recovers from (photo, attachment, notes, standing instructions, ...) now
surface as a corner toast instead of a blocking modal that has to be dismissed
-- a write action's failure (POST/PUT/DELETE) still gets the centred,
must-acknowledge dialog, since that is the one thing the user was waiting on.
Also surfaces the backend's real message on a 500 instead of a hardcoded
generic sentence.

Backend counterpart: ClerkShareWithdrawalRequestApiResource (preview-validate
endpoint, shared buildShareWithdrawalChecks()) and
DocumentReadPlatformServiceImpl (missing-file 404) land separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 22:36:15 +05:30
niteshandClaude Sonnet 5 d6d7d912c0 fix(savings-product): guard against duplicate submission on create
The product name is unique in the database, so a repeat click on Submit
while the first request is still in flight fails with a duplicate key
error even though the first request already created the product. Disables
the button and ignores repeat calls until the request settles; also drops
a stray debug console.log from the submit payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 12:39:43 +05:30
nitesh 454988d803 Merge remote-tracking branch 'origin/dev-latest' into dev-latest 2026-10-06 11:01:00 +05:30
niteshandClaude Sonnet 5 17bfe4c7e6 fix(tasks): separate Pending Share Accounts from the Pending Account screen
Removes the Pending Share Accounts section that had been bolted onto the
Pending Account screen (duplicate data/actions alongside the normal-account
table), restoring it as its own standalone queue. The standalone
pending-share-accounts route/component already existed but had no tab link
anywhere in the Approval Center nav, so it was unreachable from the UI -
added a "Pending Share Accounts" tab for it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 10:56:25 +05:30
keshav 5ab4e9eaf0 Merge remote-tracking branch 'origin/dev-latest' into dev-latest 2026-10-05 12:41:38 +05:30
keshav 4fc6784695 feat: interest posting 2026-10-05 12:39:14 +05:30
nitesh 174a2e7b6b fix(accounting,deposits): allow future dates in premature close and GL search, fix FD/RD accounting mappings 2026-10-04 16:02:15 +05:30
nitesh 9f7d9d28fd feat: Fixed Deposit - premature close, payment dialog, account actions, translations and env updates 2026-10-04 09:12:06 +05:30
keshav 1ffac4b1e5 fix(savings-product): move savings interest paid to expenses mapping 2026-10-01 14:01:24 +05:30
niteshandClaude Sonnet 5 4dee1db360 Merge remote-tracking branch 'origin/dev-latest' into dev-latest
Brings in partner's RBI Reserve / Form 1 & 9 returns UI and interest
posting reports, alongside today's FD premature-closure work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 13:22:39 +05:30
niteshandClaude Sonnet 5 0c6ebfdc1a Merge branch 'feature/bugfix' into dev-latest
Brings in partner's "Enforce same-user maker-checker restriction" and
related client-KYC-step / product-edit-diff-dialog work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 13:21:19 +05:30
keshavandClaude Opus 5.5 b6994c0b76 feat(rbi-reserve,savings): Form 1/Form 9 returns UI, branch-wise interest posting reports and summary
- Reports > Interest Posting Reports: per branch and product, generate/regenerate
  at any time, approve; branch column, HO-on-behalf marker, posted txn per account
- Reports > Interest Posting Summary (branch-wise) for the head office, with Excel export
- Day Close warns and disables Close Day while the branch's interest report is unapproved
- Savings product accounting: Interest On Savings (expense), Savings Interest Payable
  and Paid (liability rows, required)
- RBI Form 1 statutory and Form 9 return screens, reserve config updates
- Interest posting report GETs exempted from the response cache

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 13:20:01 +05:30
niteshandClaude Sonnet 5 4f02f24aae feat(deposits): RBI premature closure compliance UI, renewal-history status fix
Premature Close dialog: adds the Closure Reason dropdown, the explicit
Waive Penalty checkbox, a tenure/date preview panel, and an "Effective
Rate for this closure" display so the maker sees exactly what rate and
penalty treatment is about to apply.

Renewal History tab: the latest term's card kept showing an ACTIVE badge
even after the account was prematurely closed, since the archived
renewal-status column only ever updates on an actual renewal rollover.
For the last card only, status now cross-checks the account's own live
status instead. "Projected Maturity" now reads "Closed At" once that
term has prematurely closed, rather than implying it's still a projection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 12:19:37 +05:30
anshul 73871a0993 Enforce same-user maker-checker restriction 2026-10-01 12:04:01 +05:30
anshul 7b0cb5bb63 Merge branch 'dev-latest' into feature/bugfix 2026-09-30 16:32:46 +05:30
niteshandClaude Sonnet 5 74a51a0626 fix(products): show named GL account changes in the pending-edit diff dialog
otherGlMappings (the nested document behind Savings/Current/FD/RD Interest
Payable/Paid and similar named accounts) was excluded from the generic
field diff entirely, and the backend was silently dropping its value
anyway (see the paired backend fix). Now that the backend correctly
stages this field, diff it explicitly: find each reserved account name
across original.otherGlMappingsMap and proposed.otherGlMappings, resolve
the GL code to a readable name, and show a row only when it actually
moved -- same convention as every other field in this dialog, instead of
a raw JSON blob or nothing at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 12:40:10 +05:30
anshul 646e73d08a Merge branch 'dev-latest' into feature/bugfix 2026-09-29 10:16:58 +05:30
niteshandClaude Sonnet 5 a7ed3883df Merge branch 'feature/bugfix' into dev-latest
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 18:36:31 +05:30
niteshandClaude Sonnet 5 18c3bb6356 fix(current-accounts): show bank/branch letterhead on passbook, matching savings
Current account passbook hardcoded "LincPay Co-operative Bank Ltd." and had
no branch identification line at all, unlike the savings passbook which
pulls the bank name and branch/IFSC/MICR details from the Institution and
Branch Profile. Wire up the same OrganizationService lookup so the current
account statement shows real bank/branch details too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 18:18:35 +05:30
niteshandClaude Sonnet 5 6afa79bd37 fix: correct withdrawal-review account lookup, switch tenant to utkarshbank
- Manager withdrawal-review screen always searched the Savings account
  type regardless of the request's actual account_type, so reviewing a
  Current-account withdrawal failed with "Account not found". Reuse the
  existing SEARCH_ACCOUNT_TYPE_TO_AC_TYP mapping (already used by the
  cashier lookup path) instead of a hardcoded 'SD'.
- Point the UI tenant id at utkarshbank (was lincpay) to match the new
  backend tenant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 17:53:50 +05:30
anshul ec23d57734 Merge branch 'dev-latest' into feature/bugfix 2026-09-28 17:23:53 +05:30
nitesh 192bc7725e Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest 2026-09-28 13:43:14 +05:30
keshav eb278978bd feat: update RBI reserve return form 1 & 9 UI and FD accounting mapping 2026-09-28 13:36:29 +05:30
niteshandClaude Sonnet 5 3f6485f652 feat(fd): restrict Payment to Matured accounts, unify its behavior with Dashboard Renew
Payment showed for both Active and Matured FDs, even though an Active FD
already has Premature Close for closing early. Now Matured-only.

That also meant a genuinely matured, past-grace FD closed via the account's
own Payment button (rather than the FD Dashboard's Renew action) silently
skipped crediting its gap-period interest -- the date-locking and
gap-interest preview/crediting were gated behind dashboard-only data, not
the account's actual matured state. Both now key off the same
renewal-preview response regardless of entry point, so Payment and Renew
are functionally identical once an FD has matured, as they should be since
they're already the same dialog component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:03:47 +05:30
niteshandClaude Sonnet 5 8d0d8822f0 feat(fd): convert Premature Close to a dialog, with its full calculation shown
Was a full-page form at a dedicated route, reached by navigating away from
the account -- now a popup dialog in the same visual language as the
Payment/Renew dialog, just in this Danger Zone action's own red accent.
Also fixes a real gap in the form it replaces: the premature amount/action
options never loaded until a maker happened to touch the date picker, since
the calculation was only ever triggered on value change, never on open.

Added a breakdown panel showing exactly how the shown Maturity Amount was
reached -- Deposit Amount, Contracted Interest Rate, Pre-Closure Penalty
(when applicable), and the resulting Interest Earned -- using data the
account-view page already had, since the calculation endpoint itself only
ever returns the final number.

Removed the now-dead full-page component/route target it replaces.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:03:01 +05:30
niteshandClaude Sonnet 5 59e1eeacc3 fix(accounting): show latest journal entries first by default
The table fell back to the backend's own default order (entry_date, id
ascending) until a maker clicked a column header, so the oldest entries
sat at the top on every fresh load. Defaults the table's sort to id
descending instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:02:38 +05:30
anshul 4ca08f6ee9 feat: add new standalone components for system and organization management modules 2026-09-28 12:55:42 +05:30
anshul f451672ce7 Merge branch 'dev-latest' into feature/bugfix 2026-09-28 12:51:26 +05:30
niteshandClaude Sonnet 5 6cbb31b5c6 fix(fd): show the target account number for gap-period interest in the Renew preview
The preview panel said interest would go to "the linked Savings account"
without saying which one. Now shows its actual account number, both as its
own row and in the highlighted "paid to" line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 14:12:44 +05:30
niteshandClaude Sonnet 5 90f26d32b7 fix(products): restore default minDepositTerm on FD product create
Removing the forced default on this hidden, backend-mandatory field fixed
a false "changed" diff on product edit, but also broke plain product
create -- there's no saved value yet for a brand-new product, so it went
in blank and the backend rejected it as missing. Now only skips the
default when editing an existing product (which always has its own saved
value); create still defaults to 1, same as before that diff-dialog fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:41:07 +05:30
anshul a5ed3fde21 Merge branch 'dev-latest' into feature/bugfix 2026-09-25 11:06:16 +05:30
niteshandClaude Sonnet 5 5ee0a3fc53 feat(fd): show gap-interest renewal preview in Payment dialog before confirming a late renewal
Fetches current balance, gap days since maturity, the linked savings
account's rate, and the resulting gap-interest amount and projected new
balance, and displays them above the dashboard-renewal timing text --
so staff see exactly what a late renewal is about to credit before
confirming anything. Read-only; failure to load the preview doesn't
block the renewal itself, since the existing timing text already
explains the action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 18:21:19 +05:30
anshul 6f259ad06e Merge branch 'dev-latest' into feature/bugfix 2026-09-24 18:09:54 +05:30
keshav be35f3cbc3 feat: update TDS config, RBI reserve returns, current products, and dormancy reports 2026-09-24 18:07:54 +05:30
anshul 7b9a6394c8 Implement organization maker-checker workflows 2026-09-24 17:22:50 +05:30
niteshandClaude Sonnet 5 1eaa8cc8cf feat(fd): make the Payment dialog the FD Dashboard's Renew action too, redesign it
The Payment dialog (Transfer to Savings / Reinvest Full / Reinvest Principal Only / Partial
Reinvest, works whether an FD is Active or Matured) already had a complete, correct backend
implementation for every closure type -- including one this session found and fixed a real gap
in on the backend side (Reinvest Principal Only silently stranded its leftover interest). Rather
than duplicating that logic in a second, narrower dialog, the FD Dashboard's Renew action now
opens this same dialog in a `dashboardRenewal` mode: every closure type is still available, but
the Closed On Date is computed from the product's grace window instead of picked freely
(backdated to the original maturity date within grace, today once it's passed, picker hidden so
it can't be moved off that value), and submit() calls the new gap-interest pre-step endpoint
first when past grace, before the same generic `payment` command every other caller already used.

Also: full visual redesign (bigger dialog, gradient header, per-option accent colors, refined
spacing) applied to both entry points since they're the same component now; removes the native
"Close" action from a Matured FD's Account Actions menu, since what happens at maturity now
goes through the nightly job or this dialog, not a separate close command that bypassed both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:12:45 +05:30
niteshandClaude Sonnet 5 a652ce1093 feat(fd): make Maturity Instructions optional at account-opening, pre-fill it on edit
Maturity Instructions can now be left unselected when opening a SINGLE/DOUBLE/SPECIAL FD --
a new "-- Not selected (decide later via FD Dashboard) --" option, plus a clear button next to
the field, since a maker had no way to go back to blank once something was picked. An account
left this way is never touched by the nightly maturity job; what happens to it at maturity is
decided later, manually, from the FD Dashboard's Renew action (see the backend/dashboard
commits) -- an explicit choice here still auto-processes on its own exactly as before.

Also fixes a real, separate pre-existing bug found in the same step: editing an existing
account never read back its own already-saved Maturity Instructions/transfer-target choice --
the Edit screen always opened onto a blank picker even for an account that genuinely had one
set, because it was being read from the wrong template object (the blank per-product default
template refetched from the Product dropdown, not the actual account+template response that
carries the account's own saved values).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:08:51 +05:30
niteshandClaude Sonnet 5 50b3542dfe fix(products): show only genuinely-changed fields in the product-edit diff
Three separate bugs in the checker's "Proposed changes" dialog, found while adding the
Renewal Grace Period field above:

- The "current" side resolved an enum field's value against the wrong payload key
  (preClosurePenalInterestOnTypeId, minDepositTermTypeId, etc. are exposed on the stored
  product as an object under a differently-named field, e.g. preClosurePenalInterestOnType.id)
  so it always read as blank -- these fields showed as changed on every single edit, never
  just the ones a maker actually touched.
- Enum/option fields resolved their label against a naive `<field>Options` key that doesn't
  match Fineract's actual (inconsistent) naming, so a genuinely changed field showed its raw
  numeric id instead of the option a maker picked.
- Renewal Grace Period Days and other newer fields never appeared at all, because the
  maker-checker pending-edit mirror table didn't carry them (see the backend commit for that).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:03:15 +05:30
niteshandClaude Sonnet 5 89c091671f feat(fd): add Renewal Grace Period (Days) field to FD product Terms step
Frontend half of the new per-product grace-period field: number input alongside the existing
term fields, and stops forcing real default values (1 / the first period-frequency option) onto
minDepositTerm/minDepositTermTypeId, two hidden fields nobody actually edits through this form
-- left blank like their max-/in-multiples-of- siblings already correctly were, so a governed
product edit's "Proposed changes" diff no longer showed these as changed on every unrelated edit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:02:35 +05:30
niteshandClaude Sonnet 5 865ad8ba88 feat(fd): add FD Dashboard -- maturity ladder + manual Renew action
New screen (Deposits menu, repurposing the dead "FD Maturity Report" shell button): every
active Fixed Deposit positioned against its own maturity date (Matured / Due Soon / Upcoming /
On Track), computed client-side from maturityDate so it can never drift out of step with the
account. Reads the new lean maturity-dashboard backend endpoint.

A matured, renewable row (no maturity instruction chosen at account-opening, or an explicit
Reinvest instruction still waiting on its product's grace period) gets a Renew action wired to
the account's own Payment dialog -- see the FD account-view commit for that side of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:01:43 +05:30
anshul 08a427467a A 2026-09-23 15:51:10 +05:30
niteshandClaude Sonnet 5 3f59cc6b91 fix(cashier): route FD/RD cashier deposits through maker-checker approval
SYMPTOM
A cashier could credit an FD or RD account with cash from Transaction
Entry and the money moved in instantly, with no Manager approval -
unlike a Savings/Current deposit from the same screen, which correctly
creates a PENDING request under Approvals.

ROOT CAUSE
confirmSubmit()'s maker-checker branch for a cashier deposit only
matched row.acTyp in ('SD','CA','CB','GL'):
  if (!this.isClerk && !this.reviewMode && isDeposit &&
      (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')) {
    this.submitCashierDepositRequest(row, ...);
    return;
  }
'FD' and 'RD' are their own distinct account-type codes in this
component (see creditEntryAccountType(): 'FD' -> 'FIXED_DEPOSIT', 'RD'
-> 'RECURRING_DEPOSIT', separate from 'SD'/'CA'/'CB') and were simply
never in this list, so a deposit into either fell through every later
branch (all of which are withdrawal/disbursement-only) straight to the
generic, unguarded direct POST at the bottom of confirmSubmit().

Found by tracing why a real FD funding deposit never showed up under
Branch Ops -> Approvals: it had posted directly, no request record
ever existed for it, and it later showed up as an unexplained cash
"Excess" in the Branch Day Register close (the money was physically
counted by the cashier via denominationBreakdown in the same direct
POST, but the approved-requests-only "book" total never counted it -
see the paired backend commit for the matching approve()-side fix and
how that stale data artifact was cleaned up).

FIX
Added 'FD' and 'RD' to the account-type list in that gate, so a
cashier's FD/RD deposit now creates a PENDING
m_cashier_deposit_request via submitCashierDepositRequest() exactly
like SD/CA/CB/GL already did. buildDepositPayload() needed no changes
- it already resolves accountType via creditEntryAccountType() and
sets savingsAccountId to the FD/RD account's own id correctly; only
this one gate condition excluded them.

SCOPE
This one condition, in this one method. Every other account type's
existing deposit/withdrawal behavior on this screen is unchanged.

IF THIS BREAKS SOMETHING LATER
- A cashier FD/RD deposit posting instantly again (no PENDING request
  created) -> check this gate's account-type list still includes
  'FD'/'RD', and that isClerk/reviewMode aren't being set unexpectedly
  for a cashier session.
- The backend rejecting or misrouting the resulting APPROVED request
  -> see the paired fix in CashierDepositRequestApiResource
  (cbsnexora-backend), resolveDepositAccountCategory() /
  executeFixedDepositDeposit() / executeRecurringDepositDeposit().
- A "Vault + Cashiers" vs "book" Excess reappearing in Branch Day
  Register close for a specific FD/RD deposit -> that combination
  (physically counted denomination with no approved request) should be
  impossible with this fix in place; if it recurs, this fix (or its
  backend half) has regressed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:27:16 +05:30
niteshandClaude Sonnet 5 b0777e9845 fix(deposits): FD Single Rate Calculator sent tenureInMonths=0 for tenures under 30 days
SYMPTOM
FD Single account create screen, Rate Calculator: entering a tenure
under 30 days (e.g. 27) and clicking "Find Rate" produced a Bad
Request - "The parameter `tenureInMonths` must be greater than 0."

ROOT CAUSE
calculateRate() (fixed-deposit-account-interest-rate-chart-step.
component.ts) always converted the Days-unit tenure to months before
calling the backend calculator:
  tenureInMonths = Math.floor(enteredDays / 30)
For any tenure under 30 days this floors to 0, which the backend
correctly rejects (paired backend commit: "FD Single Rate Calculator
crashed for tenures under 30 days").

FIX
- fixed-deposits.service.ts: calculateFixedDepositInterest() -
  tenureInMonths is now optional, added optional tenureInDays.
- fixed-deposit-account-interest-rate-chart-step.component.ts -
  calculateRate(): for a Days-unit tenure >= 30 days, unchanged
  (tenureInMonths = floor(days/30)); for < 30 days, sends tenureInDays
  instead and leaves tenureInMonths undefined. Also fixed the
  API-failure fallback's periodic-compounding math, which divided by
  tenureInMonths directly and would have produced NaN once
  tenureInMonths could legitimately be undefined - it now falls back
  to (enteredDays / 30) when tenureInMonths is absent.

SCOPE
Only the Rate Calculator preview inside the FD Single/Double/Special
account create screen. Does not touch how depositPeriod /
depositPeriodFrequencyId get sent on actual account submission.

IF THIS BREAKS SOMETHING LATER
- The same 400 reappearing for a short FD Single tenure -> confirm the
  Find Rate network call carries tenureInDays and NOT a zero
  tenureInMonths; check calculateRate()'s branch choosing between the
  two.
- A wrong maturity preview number specifically when the backend API
  call fails and the frontend falls back to its own formula -> check
  the `effectiveMonths = tenureInMonths ?? enteredDays / 30` fallback
  line in calculateRate().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:21:57 +05:30
keshav a6e6220100 fix: update sponsor DD approvals, cashier transaction entry, FIU and dividend declaration 2026-09-21 10:24:08 +05:30
niteshandClaude Opus 5 3c363d0372 feat(shares): one-voucher-per-slot receipt dialogs, Apply Additional Shares as a popup
Apply Collection and Apply Additional Shares settle the same Share Collection
receipts for the same member, so they are now deliberately the same screen.

Apply Collection takes exactly two receipts and no more: one for the share value,
one for the entry fee, in two separate slots. They are different things -- the
share receipt is money moving into share capital, while the fee receipt only
proves the fee was already collected and is never posted a second time -- and
picking both out of one ticked list made that distinction invisible.

Apply Additional Shares moves from its own route to a dialog. It was a full page
a clerk had to leave the account for and come back from, to fill in a short form
with the member standing at the counter. Its old route, resolver case and action-
host branch are removed rather than left behind as dead paths.

Both screens now draw their receipts from a single pool, whether a receipt
already names the member or is a voucher paid straight into a GL account; an
unattributed one is attached at the moment it is picked, so what used to be an
Attach in one table and a tick in another is one deliberate click. The by-voucher
search and the GL-entry detail both sit behind buttons, as exceptions rather than
permanent furniture.

Dialog chrome and the receipt shape are shared (share-dialog.shared.scss,
share-receipt-option.ts) so the two cannot drift apart again.

Also fixes content being clipped at the dialog edges: the wrapper cancelled a
24px surface padding that the panel class had already zeroed, pushing every edge
outside the surface where overflow:hidden cut it off, and the height cap sat on
the scrolling body rather than the wrapper, letting the footer stack past the
bottom of the viewport.

Adds the product-wise share summary report (authorised, issued, available and
unpaid shares, distinctive range and account count per product).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 18:56:34 +05:30
nitesh dc7f668056 feat(shares): move voucher search behind a popup, not an always-visible toolbar
The GL Account / Voucher No lookup is for the exception -- a receipt the
unattributed list and the member's own list do not already carry -- so it now
sits behind a "Search a Voucher Not Listed Below" button in both Apply
Collection and Apply Additional Shares, opening the same lookup in a popup
(matching the existing receipt-detail popup) instead of occupying screen space
whether or not anyone needs it. A successful find closes the popup and the
result appears ticked in the list behind it, same as before.
2026-09-18 13:57:14 +05:30
nitesh 124f05d21d Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest
# Conflicts:
#	src/environments/.env.ts
2026-09-18 13:15:48 +05:30
niteshandClaude Sonnet 5 c7428d0cb2 feat(shares): dynamic Share Reports module, voucher attach/GL sync fixes
- Add the full Share Reports surface (final balance, account statement,
  issued/status/nominal/share-wise/guarantor/member-interest reports, enquiry,
  payment certificate, share detail, dividend statement, folio statement,
  distinctive numbers, area-wise, mailing labels/letters, share transfer) as
  standalone routed components, wired into the Share menu
- Wire the unattributed Share Collection receipt list into Apply Collection
  and Apply Additional Shares, with an Attach action independent of
  account/client
- Sync the GL Account and Voucher No fields to whichever receipt is ticked or
  found, and surface the receipt's real Dr/Cr GL entries in its detail popup
- Block Apply Additional Shares until the account's original application is
  settled

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 13:06:02 +05:30
anshulandClaude Sonnet 5 cda7c52d11 Merge branch 'dev' into feature/bugfix
Conflicts resolved in:
- src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html
- src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts

Kept dev's CKYC architecture (upload dialog, real per-artefact endpoints)
and layered feature/bugfix's fixes on top: upload file validation,
missing-file badge, silent preview download, and image/PDF view button.
Also includes the CKYC "Image of Client" upload-state fix and the
client-change state-leak fix in the same component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 16:55:32 +05:30
anshul d132bbe716 feat: add CKYC management tab, checker inbox views, and maker-checker diff utilities 2026-09-16 15:17:51 +05:30
satyamsingh_8888 6eaf9b959f Add Harbor login to deploy step so cross-node pull auth propagates to dev2
continuous-integration/drone/push Build is passing
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
2026-09-14 18:41:34 +05:30
satyamsingh_8888 b184a0909c Deploy cbsnexora UI to dev2 node (environment==dev2 constraint)
continuous-integration/drone/push Build was killed
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-09-14 18:22:00 +05:30
363 changed files with 38471 additions and 6544 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
"ng": "ng",
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.localhost.conf.js",
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" ng serve --proxy-config proxy.localhost.conf.js",
"start:share": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
"start:demo": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.conf.js",
"serve:dev": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --source-map --proxy-config proxy.localhost.conf.js",
@@ -279,7 +279,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [max]="maxDate" [formControl]="transactionDateFrom" />
<input matInput [matDatepicker]="dpFrom" [min]="minDate" [formControl]="transactionDateFrom" />
<mat-datepicker-toggle matSuffix [for]="dpFrom"></mat-datepicker-toggle>
<mat-datepicker #dpFrom></mat-datepicker>
</mat-form-field>
@@ -288,7 +288,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpTo" [min]="minDate" [max]="maxDate" [formControl]="transactionDateTo" />
<input matInput [matDatepicker]="dpTo" [min]="minDate" [formControl]="transactionDateTo" />
<mat-datepicker-toggle matSuffix [for]="dpTo"></mat-datepicker-toggle>
<mat-datepicker #dpTo></mat-datepicker>
</mat-form-field>
@@ -353,13 +353,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted From</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input
matInput
[matDatepicker]="dpSubFrom"
[min]="minDate"
[max]="maxDate"
[formControl]="submittedOnDateFrom"
/>
<input matInput [matDatepicker]="dpSubFrom" [min]="minDate" [formControl]="submittedOnDateFrom" />
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
<mat-datepicker #dpSubFrom></mat-datepicker>
</mat-form-field>
@@ -368,7 +362,7 @@
<div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label>
<mat-form-field appearance="outline" class="gl-ffield">
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [max]="maxDate" [formControl]="submittedOnDateTo" />
<input matInput [matDatepicker]="dpSubTo" [min]="minDate" [formControl]="submittedOnDateTo" />
<mat-datepicker-toggle matSuffix [for]="dpSubTo"></mat-datepicker-toggle>
<mat-datepicker #dpSubTo></mat-datepicker>
</mat-form-field>
@@ -539,7 +533,7 @@
</div>
<div class="gl-table-shell">
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
<mat-table [dataSource]="dataSource" matSort matSortActive="id" matSortDirection="desc" class="gl-mat-table">
<!-- #ID -->
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef mat-sort-header class="gl-th">#</mat-header-cell>
@@ -90,7 +90,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
// ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1);
maxDate = new Date();
maxDate: Date = null;
/** Accounting features switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
@@ -208,7 +208,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
}
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.maxDate = null;
this.paginator.pageSize = 10;
this.restoreState();
this.setFilteredOffices();
+16
View File
@@ -74,6 +74,22 @@ export class AccountsService {
return this.http.get('/fixeddepositaccounts', { params: httpParams });
}
/**
* FD Dashboard's data source: every ACTIVE FD, id/account/client/product identity, maturity date
* and deposit/maturity amounts only. Deliberately a separate, narrow endpoint rather than
* getFixedDepositAccounts() above -- that one's backend query joins in six more tables (audit
* who-did-what-when, currency, tax group) an unbounded dashboard fetch does not need, over columns
* that had no index until this feature added one (see backend migration 0571).
*/
getFixedDepositMaturityDashboard(): Observable<any> {
return this.http.get('/fixeddepositaccounts/maturity-dashboard');
}
/** RD Dashboard: running RDs with their installment position, closures and penalty income for the month. */
getRecurringDepositDashboard(): Observable<any> {
return this.http.get('/recurringdepositaccounts/dashboard');
}
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
let httpParams = new HttpParams()
.set('paged', 'true')
+4
View File
@@ -63,6 +63,8 @@ import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module';
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
import { RdDashboardModule } from './rd-dashboard/rd-dashboard.module';
import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
@@ -176,6 +178,8 @@ export function HttpLoaderFactory(http: HttpClient) {
BankCalendarModule,
DeafModule,
SavingsDailyInterestModule,
FdDashboardModule,
RdDashboardModule,
DicgcModule,
FiuModule,
GlReportsModule,
@@ -17,7 +17,12 @@
</p>
</div>
</div>
<button class="bor-btn bor-btn--xls" type="button" [disabled]="exporting || !filtered.length" (click)="exportExcel()">
<button
class="bor-btn bor-btn--xls"
type="button"
[disabled]="exporting || !filtered.length"
(click)="exportExcel()"
>
<fa-icon icon="file-excel"></fa-icon>
{{ exporting ? 'Exporting…' : 'Export' }}
</button>
@@ -132,7 +137,9 @@
<td class="num">{{ r.approvedShares | number }}</td>
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
<td class="num">{{ r.trancheCount }}</td>
<td><span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span></td>
<td>
<span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span>
</td>
<td class="mono">{{ r.activatedDate || '—' }}</td>
</tr>
} @empty {
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface RegisterRow {
accountId: number;
@@ -83,7 +84,7 @@ export class AccountRegisterComponent implements OnInit {
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -107,11 +107,26 @@
</span>
</div>
@if (canOperate) {
<button type="button" class="pg-btn pg-btn--primary" (click)="startClose(openDay)">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="startClose(openDay)"
[disabled]="interestPending.length > 0"
>
<mat-icon>lock</mat-icon> Close Day
</button>
}
</div>
@if (interestPending.length > 0) {
<div class="bd-interest-alert">
<mat-icon>warning</mat-icon>
<span>
Savings interest posting report is not approved for <strong>{{ interestPending.join(', ') }}</strong
>. Approve it in <a routerLink="/reports/interest-posting">Reports → Interest Posting Reports</a> before
closing the day.
</span>
</div>
}
} @else if (loaded && canOperate) {
<div class="bd-current bd-current--shut">
<div class="bd-current__main">
@@ -372,3 +372,30 @@
color: #d97706;
}
}
// Shown under the open day while a savings interest posting report due today is unapproved.
.bd-interest-alert {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 8px 0 12px;
padding: 10px 14px;
border: 1px solid #f59e0b;
border-radius: 8px;
background: #fef3c7;
color: #92400e;
font-size: 13px;
mat-icon {
flex-shrink: 0;
font-size: 20px;
width: 20px;
height: 20px;
}
a {
color: inherit;
font-weight: 600;
text-decoration: underline;
}
}
@@ -189,6 +189,23 @@ export class BranchDayComponent implements OnInit {
return this.totalOf(this.closeCounts);
}
/**
* Savings products whose interest posting report is due today and not yet approved. The close is
* refused by the backend while any is pending; this only says so before the user tries.
*/
interestPending: string[] = [];
private checkInterestReports(): void {
if (!this.openDay?.businessDate) {
this.interestPending = [];
return;
}
this.service
.interestDayCloseCheck(this.openDay.businessDate, this.openDay.officeId)
.pipe(catchError(() => of(null)))
.subscribe((res) => (this.interestPending = res?.pendingProducts ?? []));
}
load(): void {
this.loading = true;
this.errorMsg = '';
@@ -210,6 +227,7 @@ export class BranchDayComponent implements OnInit {
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
this.checkFirstOpenCashMigration();
this.checkInterestReports();
});
this.service
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
import { businessToday } from 'app/core/utils/business-today';
interface SummaryRow {
code: string;
@@ -1316,7 +1317,7 @@ export class CashClearingTransferSummaryComponent {
private dateTag = '2607';
constructor() {
const d = new Date();
const d = businessToday();
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
this.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`;
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -63,7 +64,7 @@ export class CashExchangeEntryComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -18,6 +18,7 @@ import { MatInputModule } from '@angular/material/input';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
@@ -90,7 +91,7 @@ export class CashSettlementComponent implements OnInit {
saving = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
@@ -17,6 +17,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -82,7 +83,7 @@ export class CashTransferComponent implements OnInit {
loadingVault = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -17,6 +17,7 @@ import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -71,7 +72,7 @@ export class CashierPositionComponent implements OnInit {
isCashierMode = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.isCashierMode = this.authService.isCashierUser();
+29 -4
View File
@@ -205,6 +205,23 @@ export class CashierService {
return this.http.get('/branchday', { params });
}
/**
* The savings products whose interest posting report is due for the date and not yet approved —
* the backend refuses to close any branch's day while one is pending.
*/
interestDayCloseCheck(
date?: string,
officeId?: number
): Observable<{ businessDate: string; canClose: boolean; pendingProducts: string[] }> {
let params = new HttpParams();
if (date) params = params.set('date', date);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<{ businessDate: string; canClose: boolean; pendingProducts: string[] }>(
'/savingsinterestpostingreports/day-close-check',
{ params }
);
}
/** Days opened and never closed — today's own open day excluded. */
branchDayMissed(officeId?: number): Observable<any> {
let params = new HttpParams();
@@ -390,10 +407,18 @@ export class CashierService {
return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } });
}
/** Receipts still sitting in Share Sundry, optionally for a single member. */
getUnappliedShareAdvances(clientId?: number): Observable<any> {
const suffix = clientId ? `?clientId=${clientId}` : '';
return this.http.get(`/shareadvancecollection${suffix}`);
/**
* Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts,
* ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL
* deposits and receipts linked to another member — which attach by voucher instead.
*
* One request rather than two: both lists come from the same table under the same filters, and
* splitting them across endpoints only meant the same rows were fetched twice and, where they
* overlapped, drawn twice on the same screen.
*/
getShareCollectionReceipts(clientId?: number): Observable<any> {
const params = clientId ? new HttpParams().set('clientId', String(clientId)) : undefined;
return this.http.get('/shareadvancecollection/unattributed', { params });
}
/** Moves a receipt out of Sundry into share capital for an activated account. */
@@ -51,6 +51,12 @@ export class ClerkShareWithdrawalService {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
}
/** Runs the exact same checklist approve() will run, against a draft that hasn't been submitted
* yet — so the clerk sees it in the confirm dialog, before committing to the request. */
previewValidate(data: any): Observable<any> {
return this.http.post('/clerk/share-withdrawal-requests/preview-validate', data);
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
}
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/** Day-wise denomination-wise closing cash collected from deposits. */
@Component({
@@ -204,7 +205,7 @@ export class ClosingCashReportComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load();
this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} });
@@ -13,6 +13,7 @@ import { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Which part of the day's work a row came from. Set by the server, one per source register.
@@ -95,7 +96,7 @@ export class DayBookComponent implements OnInit {
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -17,6 +17,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -79,7 +80,7 @@ export class InterCashierTransferComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
+2 -1
View File
@@ -14,6 +14,7 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -357,7 +358,7 @@ export class MyWorkComponent implements OnInit {
}
private todayIso(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
@@ -18,6 +18,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -85,7 +86,7 @@ export class ReconciliationComponent implements OnInit {
loadingHistory = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
@@ -8,7 +8,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
@@ -35,6 +35,35 @@ export interface CashierIssueDdDialogData {
accountNo?: string;
/** The account holder, for the same reason. */
accountHolderName?: string;
/**
* How many drafts this slip raises. Absent or 1 is the ordinary single draft and the dialog is
* unchanged; 2 or more turns it into a slip, asking for a face value, charge and beneficiary per
* draft and reporting the combined total the account will be debited for.
*/
ddCount?: number;
/**
* Whether the funding account is a GL account rather than a member's. Only affects wording: a GL
* account has no passbook, so it is not promised one.
*/
glFunded?: boolean;
/**
* The leaflets provisionally shown against each line of a slip.
*
* Only ever indicative. The server allocates the real numbers inside the locked transaction that
* submits the slip, so what is shown here is the next-available run at the moment the dialog was
* opened — accurate unless another clerk submits first, which is precisely why the numbers cannot
* be promised until submit returns them.
*/
previewDdNumbers?: string[];
}
/** One captured draft on a multi-DD slip. */
export interface DdSlipLineResult {
beneficiaryName: string;
payableAtBranch: string;
payableAtCity: string;
faceValue: number;
chargesAmount: number;
}
@Component({
@@ -56,10 +85,20 @@ export interface CashierIssueDdDialogData {
<div class="dd-dialog-header__title">
<mat-icon class="dd-icon">receipt_long</mat-icon>
<div>
<h2>{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}</h2>
<h2>
@if (isSlip) {
Multi-DD Slip · {{ lineCount }} Demand Drafts
} @else {
{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}
}
</h2>
<p>
Next available DD leaflet auto-selected ·
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser &amp; beneficiary details' }}
@if (isSlip) {
One debit funds all {{ lineCount }} drafts · Enter each draft's beneficiary and amount
} @else {
Next available DD leaflet auto-selected ·
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser &amp; beneficiary details' }}
}
</p>
</div>
</div>
@@ -69,41 +108,91 @@ export interface CashierIssueDdDialogData {
</div>
<mat-dialog-content class="dd-dialog-body">
<!-- Draft & Financials Summary Card -->
<div class="dd-summary-card">
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
<!--
Slip running total.
A slip's figures are not known until every line is typed — different drafts carry different
face values and different charges — so the total the account will be debited for is built
up here as the clerk works, rather than shown as a fixed figure up front. Answering "what
is this going to cost my member" without it would mean adding the lines by hand.
-->
@if (isSlip) {
<div class="dd-summary-card">
<div class="summary-col">
<span class="label">Drafts On This Slip</span>
<strong class="dd-number-highlight">{{ lineCount }}</strong>
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">Total Face Value</span>
<strong class="amount">₹{{ slipFaceValue | number: '1.2-2' }}</strong>
<span class="sublabel">+ Charges ₹{{ slipCharges | number: '1.2-2' }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">GST ({{ data.gstPercentage }}%)</span>
<strong class="amount">₹{{ slipGst | number: '1.2-2' }}</strong>
<span class="sublabel">on charges only</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col highlight-col">
<span class="label">Total To Debit</span>
<strong class="total-amount">₹{{ slipTotal | number: '1.2-2' }}</strong>
<span class="sublabel">{{ data.accountNo || 'Account' }} · one debit</span>
</div>
</div>
<!--
Said plainly because the numbers on screen are the one thing in this dialog the clerk
cannot rely on. They are the next-available run as it stood when the dialog opened; the
binding allocation happens inside the submit transaction, so a slip filed after another
clerk's gets the following numbers instead.
-->
<div class="dd-preview-note">
<mat-icon>info</mat-icon>
<div>
DD numbers below are <strong>provisional</strong>. The final numbers are allocated when the slip is
submitted and will be confirmed on the receipt.
</div>
</div>
} @else {
<!-- Draft & Financials Summary Card -->
<div class="dd-summary-card">
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
Leaflet" named the stock item rather than the draft, so it was not obvious that this
is the DD number being issued. -->
<div class="summary-col">
<span class="label">DD Number Being Issued</span>
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
<div class="summary-col">
<span class="label">DD Number Being Issued</span>
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">Face Value</span>
<strong class="amount">₹{{ data.faceValue | number: '1.2-2' }}</strong>
<span class="sublabel">+ Charges ₹{{ data.chargesAmount | number: '1.2-2' }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">GST ({{ data.gstPercentage }}%)</span>
<strong class="amount">₹{{ data.gstAmount | number: '1.2-2' }}</strong>
<span class="sublabel">on charges only</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col highlight-col">
<span class="label">{{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }}</span>
<strong class="total-amount">₹{{ data.totalCashPayable | number: '1.2-2' }}</strong>
<span class="sublabel">
@if (isAccountDebit) {
{{ data.accountNo || 'Account' }}
} @else {
Denominations verified
}
</span>
</div>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">Face Value</span>
<strong class="amount">₹{{ data.faceValue | number: '1.2-2' }}</strong>
<span class="sublabel">+ Charges ₹{{ data.chargesAmount | number: '1.2-2' }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">GST ({{ data.gstPercentage }}%)</span>
<strong class="amount">₹{{ data.gstAmount | number: '1.2-2' }}</strong>
<span class="sublabel">on charges only</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col highlight-col">
<span class="label">{{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }}</span>
<strong class="total-amount">₹{{ data.totalCashPayable | number: '1.2-2' }}</strong>
<span class="sublabel">
@if (isAccountDebit) {
{{ data.accountNo || 'Account' }}
} @else {
Denominations verified
}
</span>
</div>
</div>
}
<!--
The account funding the draft, stated before the form.
@@ -121,123 +210,237 @@ export interface CashierIssueDdDialogData {
· {{ data.accountHolderName }}
}
<div class="dd-funding-sub">
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
approves. No cash is taken at the counter.
@if (isSlip) {
₹{{ slipTotal | number: '1.2-2' }} will be debited from this account as a
<strong>single transaction</strong> covering all {{ lineCount }} drafts, when a checker approves the
slip —
<!-- A GL account has no passbook, so the reassurance a member gets is phrased for
the ledger instead of promising a statement line that does not exist. -->
@if (isGlFunded) {
one journal entry against this GL account, not {{ lineCount }}.
} @else {
the member's passbook shows one entry, not {{ lineCount }}.
}
} @else {
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
approves. No cash is taken at the counter.
}
</div>
</div>
</div>
}
<form [formGroup]="form" class="dd-form">
<!--
<!--
A slip's drafts, one card each.
Face value and charge sit on the line rather than on the slip because that is how the bank
actually uses this: three drafts to three institutions are three different amounts, and the
charge is levied per draft. The server stores and posts them per line for the same reason.
-->
@if (isSlip) {
<form [formGroup]="slipForm" class="dd-form">
<div formArrayName="lines">
@for (lineCtrl of slipLines.controls; track $index) {
<div class="dd-line-card" [formGroupName]="$index">
<div class="dd-line-head">
<span class="dd-line-badge">DD {{ $index + 1 }} of {{ lineCount }}</span>
@if (previewNumberAt($index)) {
<span class="dd-line-number">No. {{ previewNumberAt($index) }} <em>(provisional)</em></span>
}
<span class="dd-line-total"> Debit ₹{{ lineTotalAt($index) | number: '1.2-2' }} </span>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
<input
matInput
formControlName="beneficiaryName"
placeholder="e.g. UNIVERSITY OF MUMBAI"
maxlength="120"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="lineCtrl.get('beneficiaryName')?.hasError('required')"
>Beneficiary name is required</mat-error
>
<mat-error *ngIf="lineCtrl.get('beneficiaryName')?.hasError('maxlength')"
>Max 120 characters</mat-error
>
</mat-form-field>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - Branch *</mat-label>
<input
matInput
formControlName="payableAtBranch"
placeholder="e.g. FORT BRANCH"
maxlength="150"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="lineCtrl.get('payableAtBranch')?.hasError('required')"
>Payable Branch is required</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - City *</mat-label>
<input
matInput
formControlName="payableAtCity"
placeholder="e.g. MUMBAI"
maxlength="100"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="lineCtrl.get('payableAtCity')?.hasError('required')"
>Payable City is required</mat-error
>
</mat-form-field>
</div>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Face Value *</mat-label>
<input matInput type="number" formControlName="faceValue" placeholder="0.00" />
<span matPrefix>₹&nbsp;</span>
<mat-error *ngIf="lineCtrl.get('faceValue')?.hasError('required')"
>Face value is required</mat-error
>
<mat-error *ngIf="lineCtrl.get('faceValue')?.hasError('min')"
>Face value must be greater than zero</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Charges</mat-label>
<input matInput type="number" formControlName="chargesAmount" placeholder="0.00" />
<span matPrefix>₹&nbsp;</span>
<mat-hint>GST ₹{{ lineGstAt($index) | number: '1.2-2' }} at {{ data.gstPercentage }}%</mat-hint>
<mat-error *ngIf="lineCtrl.get('chargesAmount')?.hasError('min')"
>Charges cannot be negative</mat-error
>
</mat-form-field>
</div>
</div>
}
</div>
</form>
} @else {
<form [formGroup]="form" class="dd-form">
<!--
Purchaser identity is asked for only when the bank has none: a walk-in paying cash. On an
account debit the purchaser IS the account holder, already identified and KYC'd, and
re-capturing a name and Aadhaar here would invite a mismatch against the account on record.
-->
@if (!isAccountDebit) {
<div class="form-section-title">Walk-in Purchaser Details</div>
@if (!isAccountDebit) {
<div class="form-section-title">Walk-in Purchaser Details</div>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Walk-in Purchaser Name *</mat-label>
<input
matInput
formControlName="walkinPurchaserName"
placeholder="e.g. RAMESH CHANDRA"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('required')"
>Purchaser name is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('minlength')"
>Must be at least 3 characters</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>12-Digit Aadhaar Number *</mat-label>
<input
matInput
formControlName="walkinPurchaserAadhaar"
placeholder="12-digit Aadhaar"
maxlength="12"
/>
<mat-hint>Masked to last 4 digits before storage</mat-hint>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('required')"
>Aadhaar number is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('pattern')"
>Must be exactly 12 numeric digits</mat-error
>
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Purchaser Address / Location *</mat-label>
<input
matInput
formControlName="walkinPurchaserAddress"
placeholder="e.g. 14, SECTOR 5, MAIN ROAD, MUMBAI"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserAddress')?.hasError('required')"
>Address is required</mat-error
>
</mat-form-field>
}
<!-- Beneficiary Section -->
<div class="form-section-title">Beneficiary & Payment Details</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
<input
matInput
formControlName="beneficiaryName"
placeholder="e.g. UNIVERSITY OF MUMBAI"
maxlength="120"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('required')"
>Beneficiary name is required</mat-error
>
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('maxlength')">Max 120 characters</mat-error>
</mat-form-field>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Walk-in Purchaser Name *</mat-label>
<mat-label>Payable At - Branch *</mat-label>
<input
matInput
formControlName="walkinPurchaserName"
placeholder="e.g. RAMESH CHANDRA"
formControlName="payableAtBranch"
placeholder="e.g. FORT BRANCH"
maxlength="150"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('required')"
>Purchaser name is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('minlength')"
>Must be at least 3 characters</mat-error
<mat-error *ngIf="form.get('payableAtBranch')?.hasError('required')"
>Payable Branch is required</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>12-Digit Aadhaar Number *</mat-label>
<mat-label>Payable At - City *</mat-label>
<input
matInput
formControlName="walkinPurchaserAadhaar"
placeholder="12-digit Aadhaar"
maxlength="12"
formControlName="payableAtCity"
placeholder="e.g. MUMBAI"
maxlength="100"
style="text-transform: uppercase;"
/>
<mat-hint>Masked to last 4 digits before storage</mat-hint>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('required')"
>Aadhaar number is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('pattern')"
>Must be exactly 12 numeric digits</mat-error
>
<mat-error *ngIf="form.get('payableAtCity')?.hasError('required')">Payable City is required</mat-error>
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Purchaser Address / Location *</mat-label>
<input
matInput
formControlName="walkinPurchaserAddress"
placeholder="e.g. 14, SECTOR 5, MAIN ROAD, MUMBAI"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserAddress')?.hasError('required')"
>Address is required</mat-error
>
</mat-form-field>
}
<!-- Beneficiary Section -->
<div class="form-section-title">Beneficiary & Payment Details</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
<input
matInput
formControlName="beneficiaryName"
placeholder="e.g. UNIVERSITY OF MUMBAI"
maxlength="120"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('required')"
>Beneficiary name is required</mat-error
>
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('maxlength')">Max 120 characters</mat-error>
</mat-form-field>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - Branch *</mat-label>
<input
matInput
formControlName="payableAtBranch"
placeholder="e.g. FORT BRANCH"
maxlength="150"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('payableAtBranch')?.hasError('required')"
>Payable Branch is required</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - City *</mat-label>
<input
matInput
formControlName="payableAtCity"
placeholder="e.g. MUMBAI"
maxlength="100"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('payableAtCity')?.hasError('required')">Payable City is required</mat-error>
</mat-form-field>
</div>
</form>
</form>
}
</mat-dialog-content>
<mat-dialog-actions class="dd-dialog-actions" align="end">
@if (isSlip) {
<span class="dd-actions-total">
{{ lineCount }} drafts · <strong>₹{{ slipTotal | number: '1.2-2' }}</strong> to debit
</span>
}
<button mat-button type="button" (click)="onClose()">Cancel</button>
<button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()">
Save DD Details
<button mat-flat-button color="primary" [disabled]="activeForm.invalid" (click)="onSubmit()">
{{ isSlip ? 'Save Slip Details' : 'Save DD Details' }}
</button>
</mat-dialog-actions>
</div>
@@ -395,6 +598,81 @@ export interface CashierIssueDdDialogData {
line-height: 1.5;
color: #64748b;
}
/* One card per draft on a slip — the boundary is what stops a clerk typing line 2's
beneficiary against line 1's amount. */
.dd-line-card {
border: 1px solid #e2e8f0;
border-left: 3px solid #0369a1;
border-radius: 8px;
padding: 14px 16px 4px;
margin-bottom: 14px;
background: #fcfdff;
}
.dd-line-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.dd-line-badge {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #ffffff;
background: #0369a1;
padding: 3px 9px;
border-radius: 999px;
}
.dd-line-number {
font-family: monospace;
font-size: 12px;
color: #475569;
}
.dd-line-number em {
font-style: normal;
color: #94a3b8;
font-size: 11px;
}
.dd-line-total {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: #065f46;
background: #ecfdf5;
border: 1px solid #a7f3d0;
padding: 3px 10px;
border-radius: 6px;
}
.dd-preview-note {
display: flex;
gap: 8px;
align-items: flex-start;
margin: 0 0 16px;
padding: 9px 12px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
color: #78350f;
}
.dd-preview-note mat-icon {
color: #b45309;
font-size: 18px;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.dd-actions-total {
margin-right: auto;
font-size: 13px;
color: #475569;
}
.dd-actions-total strong {
color: #065f46;
}
`
]
})
@@ -404,13 +682,127 @@ export class CashierIssueDdDialogComponent implements OnInit {
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
form!: FormGroup;
/** Built only in slip mode; `form` stays untouched so the single-draft path is unchanged. */
slipForm!: FormGroup;
/** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */
get isAccountDebit(): boolean {
return this.data.paymentMethod === 'ACCOUNT_DEBIT';
}
/** How many drafts this dialog is capturing — 1 unless the clerk asked for a slip. */
get lineCount(): number {
const n = Number(this.data.ddCount) || 1;
return n > 1 ? n : 1;
}
/** Whether this dialog is capturing a multi-DD slip rather than a single draft. */
get isSlip(): boolean {
return this.lineCount > 1;
}
/** Whether the debit lands on a GL account rather than a member's — wording only. */
get isGlFunded(): boolean {
return !!this.data.glFunded;
}
/** The form the Save button validates against — whichever of the two was built. */
get activeForm(): FormGroup {
return this.isSlip ? this.slipForm : this.form;
}
get slipLines(): FormArray {
return this.slipForm.get('lines') as FormArray;
}
/** The provisional leaflet number shown against a line, if the caller supplied a run of them. */
previewNumberAt(index: number): string | null {
return this.data.previewDdNumbers?.[index] ?? null;
}
private lineValueAt(index: number, field: string): number {
return Number(this.slipLines.at(index)?.get(field)?.value) || 0;
}
/**
* GST on one line's charge.
*
* Rounded per line, matching the server, which computes and stores GST against each draft rather
* than once for the slip. Summing rounded lines and rounding a summed charge can differ by a
* paisa, and it is the per-line figure that ends up on the draft.
*/
lineGstAt(index: number): number {
const charges = this.lineValueAt(index, 'chargesAmount');
if (charges <= 0) return 0;
return Math.round(charges * (Number(this.data.gstPercentage) || 0)) / 100;
}
/** What one line adds to the debit — its face value, its charge and the GST on that charge. */
lineTotalAt(index: number): number {
return this.lineValueAt(index, 'faceValue') + this.lineValueAt(index, 'chargesAmount') + this.lineGstAt(index);
}
get slipFaceValue(): number {
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'faceValue'), 0);
}
get slipCharges(): number {
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'chargesAmount'), 0);
}
get slipGst(): number {
return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineGstAt(i), 0);
}
/** The figure the account is debited for — rounded up to the whole rupee, as the single flow does. */
get slipTotal(): number {
return Math.ceil(this.slipFaceValue + this.slipCharges + this.slipGst - 1e-9);
}
ngOnInit(): void {
if (this.isSlip) {
this.slipForm = this.fb.group({
lines: this.fb.array(
Array.from({ length: this.lineCount }, () =>
this.fb.group({
beneficiaryName: [
'',
[
Validators.required,
Validators.maxLength(120)
]
],
payableAtBranch: [
'',
[Validators.required]
],
payableAtCity: [
'',
[Validators.required]
],
// Zero is rejected rather than merely warned about: the server refuses a line with a
// face value at or below zero, and a slip that fails there has already cost the clerk
// every other line they typed.
faceValue: [
null as
| number
| null,
[
Validators.required,
Validators.min(0.01)
]
],
chargesAmount: [
0,
[Validators.min(0)]
]
})
)
)
});
return;
}
// Walk-in identity is required for cash and rejected outright for an account debit — the server
// refuses a walk-in name alongside an account — so the controls are only built when they apply.
// Registering them unconditionally and merely hiding them would hold the form invalid on a
@@ -477,7 +869,25 @@ export class CashierIssueDdDialogComponent implements OnInit {
}
onSubmit(): void {
if (this.form.invalid) return;
if (this.activeForm.invalid) return;
// A slip closes with its lines. There is no walk-in branch here: a slip is only ever offered on
// a member-account debit, where the purchaser is the account holder already on record.
if (this.isSlip) {
const lines: DdSlipLineResult[] = this.slipLines.controls.map((c) => {
const v = c.value;
return {
beneficiaryName: (v.beneficiaryName || '').trim().toUpperCase(),
payableAtBranch: (v.payableAtBranch || '').trim().toUpperCase(),
payableAtCity: (v.payableAtCity || '').trim().toUpperCase(),
faceValue: Number(v.faceValue) || 0,
chargesAmount: Number(v.chargesAmount) || 0
};
});
this.dialogRef.close({ lines });
return;
}
const val = this.form.value;
const beneficiary = {
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
@@ -319,9 +319,13 @@
[value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[class.ws-amt-input--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
"
[class.ws-amt-input--locked]="
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
"
/>
</div>
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
@@ -331,6 +335,19 @@
}}/share</span
>
}
<!-- On a slip the face value belongs to each draft, so the row total is the sum of
them and is filled in by the dialog rather than typed here. A clerk-typed
figure could disagree with the drafts, and the account would be debited for
a total the drafts do not add up to. -->
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">
{{
row.ddLines?.length
? 'Total of ' + row.ddLines?.length + ' drafts'
: 'Set per draft in Fill DD Details'
}}
</span>
}
</div>
@if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
<div class="ws-f ws-f--cheqno">
@@ -410,9 +427,36 @@
<div class="ws-group ws-group--flex">
<span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span>
<div class="ws-group__fields">
<!-- How many drafts this one debit funds. Typed before the details are filled in,
because it is what decides how many beneficiary forms the dialog presents.
Offered only where the server can honour it: a member savings/current
account debit, never a GL or cash draft. -->
@if (canRowBeMultiDd(row)) {
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="Number of demand drafts to raise against this single debit"
>NO. OF DD</label
>
<div class="ws-amt-wrap">
<input
class="ws-f__ctl ws-amt-input"
type="number"
min="1"
[max]="maxDdSlipLines"
step="1"
[value]="ddCountFor(row)"
(input)="onDdCountChange(row, +$any($event.target).value)"
(click)="$event.stopPropagation()"
placeholder="1"
/>
</div>
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">One debit · {{ ddCountFor(row) }} drafts</span>
}
</div>
}
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
<div class="ws-amt-wrap">
<div class="ws-amt-wrap" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
<span class="ws-amt-prefix">₹</span>
<input
class="ws-f__ctl ws-amt-input"
@@ -421,8 +465,15 @@
(input)="row.ddCharges = +$any($event.target).value"
(click)="$event.stopPropagation()"
placeholder="0.00"
[readonly]="isMultiDdRow(row)"
[class.ws-amt-input--locked]="isMultiDdRow(row)"
/>
</div>
<!-- Charges are levied per draft and can differ between them, so on a slip they
are entered against each draft and totalled here. -->
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">Set per draft</span>
}
</div>
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
@@ -433,7 +484,7 @@
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddGstAmountFor(row) | number: '1.2-2'"
[value]="ddGstDisplayFor(row)"
readonly
/>
</div>
@@ -447,7 +498,7 @@
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddTotalPayableFor(row)"
[value]="ddTotalDisplayFor(row)"
readonly
/>
</div>
@@ -460,18 +511,27 @@
[disabled]="saving"
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
[title]="
isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
isMultiDdRow(row)
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
: isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
"
>
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
row.ddDetails ? 'edit_note' : 'post_add'
row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
}}</mat-icon>
<!-- Names the leaflet once details are captured, so the clerk can see at a
glance which rows are ready before pressing Save. -->
glance which rows are ready before pressing Save. A slip names the count
instead: its numbers are not allocated until it is submitted. -->
<span>{{
row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details'
row.ddLines?.length
? row.ddLines?.length + ' Drafts Captured · Edit Slip'
: row.ddDetails
? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
: isMultiDdRow(row)
? 'Fill ' + ddCountFor(row) + ' DD Details'
: 'Fill DD Details'
}}</span>
</button>
</div>
@@ -1694,10 +1754,12 @@
[value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)"
[placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
"
[class.ws-amt-input--locked]="
!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)
isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest))
"
/>
</div>
@@ -1708,6 +1770,19 @@
}}/share</span
>
}
<!-- On a slip the face value belongs to each draft, so the row total is the
sum of them and is filled in by the dialog rather than typed here. A
clerk-typed figure could disagree with the drafts, and the account would
be debited for a total the drafts do not add up to. -->
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">
{{
row.ddLines?.length
? 'Total of ' + row.ddLines?.length + ' drafts'
: 'Set per draft in Fill DD Details'
}}
</span>
}
</div>
@if (
row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')
@@ -1772,9 +1847,38 @@
<div class="ws-group ws-group--flex">
<span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span>
<div class="ws-group__fields">
<!-- How many drafts this one debit funds. Typed before the details are filled
in, because it is what decides how many beneficiary forms the dialog
presents. Offered only where the server can honour it: a member
savings/current account debit, never a GL or cash draft. -->
@if (canRowBeMultiDd(row)) {
<div class="ws-f ws-f--amt">
<label
class="ws-f__lbl"
title="Number of demand drafts to raise against this single debit"
>NO. OF DD</label
>
<div class="ws-amt-wrap">
<input
class="ws-f__ctl ws-amt-input"
type="number"
min="1"
[max]="maxDdSlipLines"
step="1"
[value]="ddCountFor(row)"
(input)="onDdCountChange(row, +$any($event.target).value)"
(click)="$event.stopPropagation()"
placeholder="1"
/>
</div>
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">One debit · {{ ddCountFor(row) }} drafts</span>
}
</div>
}
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="DD issuance charges / commission">CHARGES</label>
<div class="ws-amt-wrap">
<div class="ws-amt-wrap" [class.ws-amt-wrap--locked]="isMultiDdRow(row)">
<span class="ws-amt-prefix">₹</span>
<input
class="ws-f__ctl ws-amt-input"
@@ -1783,8 +1887,15 @@
(input)="row.ddCharges = +$any($event.target).value"
(click)="$event.stopPropagation()"
placeholder="0.00"
[readonly]="isMultiDdRow(row)"
[class.ws-amt-input--locked]="isMultiDdRow(row)"
/>
</div>
<!-- Charges are levied per draft and can differ between them, so on a slip
they are entered against each draft and totalled here. -->
@if (isMultiDdRow(row)) {
<span class="ws-f__hint">Set per draft</span>
}
</div>
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="GST on charges, at the admin-configured rate">
@@ -1795,7 +1906,7 @@
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddGstAmountFor(row) | number: '1.2-2'"
[value]="ddGstDisplayFor(row)"
readonly
/>
</div>
@@ -1809,7 +1920,7 @@
<input
class="ws-f__ctl ws-amt-input ws-amt-input--locked"
type="text"
[value]="ddTotalPayableFor(row)"
[value]="ddTotalDisplayFor(row)"
readonly
/>
</div>
@@ -1822,13 +1933,28 @@
[disabled]="saving"
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
[title]="
isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
isMultiDdRow(row)
? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
: isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
"
>
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
<span>Fill DD Details</span>
<mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
}}</mat-icon>
<!-- Names what has been captured, so the clerk can see at a glance which
rows are ready before pressing Save. A slip names its count instead:
its numbers are not allocated until it is submitted. -->
<span>{{
row.ddLines?.length
? row.ddLines?.length + ' Drafts Captured · Edit Slip'
: row.ddDetails
? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
: isMultiDdRow(row)
? 'Fill ' + ddCountFor(row) + ' DD Details'
: 'Fill DD Details'
}}</span>
</button>
</div>
</div>
@@ -11,7 +11,7 @@ import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError, debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
import { forkJoin, of, Subject } from 'rxjs';
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
@@ -33,6 +33,7 @@ import {
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
import { OrganizationService } from '../../organization/organization.service';
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
import { businessToday, businessTodayIso } from 'app/core/utils/business-today';
interface TxnRow {
typ: string;
@@ -91,6 +92,40 @@ interface TxnRow {
fundingAccountId?: number;
fundingClientId?: number;
};
/**
* How many drafts this one row raises. 1 (or unset) is the ordinary single draft; 2 or more make
* the row a multi-DD slip — one debit funding several drafts, each to its own beneficiary.
*
* Typed by the clerk BEFORE opening the dialog, so the dialog knows how many beneficiary forms to
* present. Only a member-account debit can be a slip (see canRowBeMultiDd()).
*/
ddCount?: number | null;
/**
* The per-draft lines of a multi-DD slip, captured in the dialog and held here until Save — the
* same "fill now, commit on Save" contract as ddDetails above.
*
* Each line carries its OWN face value and charges: a slip's drafts are routinely for different
* amounts to different beneficiaries, and the charge is per draft rather than per slip. That is
* why the row's own AMOUNT and CHARGES fields are locked and derived from these lines while a
* slip is being built — a clerk-typed row total could disagree with the lines, and the account
* would then be debited for a figure the drafts don't add up to.
*/
ddLines?: DdSlipLine[];
/** The inventory the slip's leaflets are drawn from. The batch endpoint allocates by inventory
* rather than by leaflet, since the numbers are only picked inside its own locked transaction. */
ddInventoryId?: number;
/** Resolved funding account for a slip, captured alongside the lines for the same reason. */
ddFundingAccountId?: number;
ddFundingClientId?: number;
}
/** One draft on a multi-DD slip — its own beneficiary, its own face value, its own charges. */
interface DdSlipLine {
beneficiaryName: string;
payableAtBranch: string;
payableAtCity: string;
faceValue: number;
chargesAmount: number;
}
interface Denomination {
@@ -587,7 +622,7 @@ export class AccountStatementDialogComponent implements OnInit {
@ViewChild('statementContent') statementContent!: ElementRef;
today = new Date().toISOString().slice(0, 10);
today = businessTodayIso();
fromDate = '';
toDate = this.today;
@@ -1836,7 +1871,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
saving = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
this.addRow();
this.loadDenominations();
@@ -2069,7 +2104,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
private loadOpeningPosition(): void {
const d = new Date();
const d = businessToday();
const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.cashierService
.getCashierPositionForMe(asOfDate)
@@ -2415,19 +2450,159 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
*/
entryTypeOptions: { id: number; name: string }[] = [];
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
/**
* Whether this row may raise more than one draft off a single debit.
*
* Any funding source the clerk debits: a member's savings/current account, or a GL account for an
* institutional slip. Cash is the exception the batch endpoint still refuses, and for a real
* reason rather than an arbitrary one — a slip is funded by one payment, and one counted pile of
* notes cannot be apportioned across drafts without the denomination total ceasing to prove what
* was actually taken at the counter.
*/
canRowBeMultiDd(row: TxnRow): boolean {
return this.isDdAccountDebitRow(row);
}
/** Whether the clerk has asked this row for more than one draft. */
isMultiDdRow(row: TxnRow): boolean {
return this.canRowBeMultiDd(row) && (Number(row.ddCount) || 1) > 1;
}
/** How many drafts this row raises — always at least 1. */
ddCountFor(row: TxnRow): number {
const n = Number(row.ddCount) || 1;
return n > 1 ? n : 1;
}
/**
* Clamps a typed DD count into range and discards lines that no longer fit.
*
* Lowering the count after the dialog has been filled would otherwise leave lines beyond the new
* count still on the row: invisible to the clerk, but still sent at Save and still debited for.
*/
onDdCountChange(row: TxnRow, value: number): void {
const n = Math.floor(Number(value) || 1);
const previous = this.ddCountFor(row);
row.ddCount = n < 1 ? 1 : n > this.maxDdSlipLines ? this.maxDdSlipLines : n;
if (row.ddLines && row.ddLines.length !== row.ddCount) {
this.clearDdCapture(row);
}
// Moving onto a slip empties the row's own amount and charge, because on a slip they mean
// nothing: each draft carries its own face value and its own charge, entered in the dialog, and
// these two fields become the total of those. Leaving a previously typed figure sitting here
// would read as the slip's total while describing only one draft — which is exactly what showed
// ₹1,012 against four drafts of ₹1,000.
if (row.ddCount > 1 && previous <= 1) {
row.amount = null;
row.ddCharges = null;
}
}
/** Mirrors MAX_SLIP_LINES on the server, so an over-long slip is refused here with a clear message. */
readonly maxDdSlipLines = 20;
/**
* The run of leaflet numbers a slip will probably draw, for display only.
*
* Counts up from the next-available number, preserving its width so 000123 reads as 000124 rather
* than 124. Indicative by nature — the binding allocation happens inside the server's submit
* transaction, and a slip filed after another clerk's gets the numbers following theirs — which is
* why the dialog labels these provisional and the confirmed numbers come back on submission.
*
* A number that is not a plain run of digits (a prefixed or hyphenated series) cannot be counted
* on safely, so nothing is shown rather than something wrong.
*/
private previewDdNumbersFrom(firstDdNumber: string, count: number): string[] | undefined {
const first = (firstDdNumber || '').trim();
if (!/^\d+$/.test(first)) return undefined;
const width = first.length;
const start = Number(first);
if (!Number.isSafeInteger(start)) return undefined;
return Array.from({ length: count }, (_v, i) => String(start + i).padStart(width, '0'));
}
/** GST on a single charge figure, at the admin-configured rate. */
private ddGstOn(charges: number): number {
const c = Number(charges) || 0;
if (c <= 0) return 0;
return Math.round(c * this.ddGstPercentage) / 100;
}
/**
* GST on DD charges only (never on the DD face value), at the admin-configured rate.
*
* On a slip this is the sum of each line's own GST rather than GST on the summed charges. The two
* can differ by a paisa once rounding is applied per line, and it is the per-line figure that is
* stored against each draft and posted by the server — so the row must show that same total.
*/
ddGstAmountFor(row: TxnRow): number {
const charges = Number(row.ddCharges) || 0;
if (charges <= 0) return 0;
return Math.round(charges * this.ddGstPercentage) / 100;
if (row.ddLines?.length) {
return row.ddLines.reduce((sum, l) => sum + this.ddGstOn(l.chargesAmount), 0);
}
return this.ddGstOn(row.ddCharges ?? 0);
}
/** Face value of the row — the sum of every line once a slip has been captured. */
ddFaceValueFor(row: TxnRow): number {
if (row.ddLines?.length) {
return row.ddLines.reduce((sum, l) => sum + (Number(l.faceValue) || 0), 0);
}
return Number(row.amount) || 0;
}
/** Charges on the row — the sum of every line's own charge once a slip has been captured. */
ddChargesFor(row: TxnRow): number {
if (row.ddLines?.length) {
return row.ddLines.reduce((sum, l) => sum + (Number(l.chargesAmount) || 0), 0);
}
return Number(row.ddCharges) || 0;
}
/** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */
ddTotalPayableFor(row: TxnRow): number {
const amount = Number(row.amount) || 0;
const charges = Number(row.ddCharges) || 0;
const gst = this.ddGstAmountFor(row);
return Math.ceil(amount + charges + gst - 1e-9);
return Math.ceil(this.ddFaceValueFor(row) + this.ddChargesFor(row) + this.ddGstAmountFor(row) - 1e-9);
}
/**
* What the TOTAL field shows — a figure, or a dash while a slip has nothing to total yet.
*
* A slip whose drafts have not been entered has no total: its face values and charges live on the
* lines, and there are no lines. Showing ₹0 there would read as a real total of zero rather than
* as "not yet known", so the field says so instead.
*/
ddTotalDisplayFor(row: TxnRow): string {
if (this.isSlipAwaitingDetails(row)) {
return '—';
}
return String(this.ddTotalPayableFor(row));
}
/** GST field's text, dashed for the same reason as the total while a slip is still empty. */
ddGstDisplayFor(row: TxnRow): string {
if (this.isSlipAwaitingDetails(row)) {
return '—';
}
return this.ddGstAmountFor(row).toFixed(2);
}
/** A slip the clerk has asked for but not yet filled in — nothing to total from. */
isSlipAwaitingDetails(row: TxnRow): boolean {
return this.isMultiDdRow(row) && !row.ddLines?.length;
}
/**
* Forgets a row's captured draft details, leaving the row itself intact.
*
* Both shapes are cleared together on purpose: a row carrying a stale ddDetails alongside fresh
* ddLines (or the reverse) would be read by whichever of the two Save paths it reached first, and
* the clerk would have no way of seeing which one went.
*/
private clearDdCapture(row: TxnRow): void {
row.ddDetails = undefined;
row.ddLines = undefined;
row.ddInventoryId = undefined;
row.ddFundingAccountId = undefined;
row.ddFundingClientId = undefined;
}
get isSelectedRowDdCashGl(): boolean {
@@ -2468,11 +2643,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
]);
return;
}
// On a slip the face value lives on each line, typed in the dialog that is about to open — the
// row's own AMOUNT is the sum of those lines and cannot exist yet. Demanding it here would make
// a slip impossible to start.
const isSlip = this.isMultiDdRow(row);
const faceValue = Number(row.amount);
if (!faceValue || faceValue <= 0) {
if (!isSlip && (!faceValue || faceValue <= 0)) {
this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']);
return;
}
if (isSlip && !this.isDdAccountDebitRow(row)) {
this.showAlert('error', 'Multiple DDs Not Available', [
'More than one draft can only be raised against a member savings or current account.',
'Set NO. OF DD back to 1 for this row.'
]);
return;
}
const chargesAmount = Number(row.ddCharges) || 0;
const gstAmount = this.ddGstAmountFor(row);
const totalCashPayable = this.ddTotalPayableFor(row);
@@ -2508,8 +2694,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
// Only a cash draft has notes to reconcile. An account debit takes no cash at the counter, so a
// denomination count left over from an earlier row must not be checked against it.
if (!accountDebit && this.denominationTotal > 0 && Math.abs(this.denominationTotal - totalCashPayable) > 0.005) {
// denomination count left over from an earlier row must not be checked against it. A slip is
// always an account debit, and its total is not known until the dialog is filled in either.
if (
!accountDebit &&
!isSlip &&
this.denominationTotal > 0 &&
Math.abs(this.denominationTotal - totalCashPayable) > 0.005
) {
this.showAlert('error', 'Denomination Mismatch', [
`Cash denomination counted (₹${this.denominationTotal}) does not match total payable cash (₹${totalCashPayable}).`,
'Please verify and match the physical cash denomination count.'
@@ -2532,8 +2724,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const selectedBank = this.sponsorBanks.find((b: any) => b.id === row.sponsorBankId);
const branchName = this.branchNameFor(row.br) || 'Branch ' + row.br;
// A slip is allocated by inventory, not leaflet: the server picks the whole run inside its
// own locked transaction. Without the inventory behind this branch's next-available leaflet
// there is nothing to allocate from, so the slip is refused here rather than at submit.
if (isSlip && !leaflet.inventoryId) {
this.showAlert('error', 'DD Inventory Not Identified', [
'The DD inventory behind this branch and sponsor bank could not be identified, so multiple drafts cannot be allocated together.',
'Set NO. OF DD back to 1, or ask an administrator to check the DD inventory registration.'
]);
return;
}
const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, {
width: '600px',
width: isSlip ? '760px' : '600px',
disableClose: true,
data: {
leaflet,
@@ -2546,7 +2749,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
totalCashPayable,
paymentMethod: accountDebit ? ('ACCOUNT_DEBIT' as const) : ('CASH' as const),
accountNo: accountDebit ? row.acNo : undefined,
accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined
accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined,
ddCount: isSlip ? this.ddCountFor(row) : undefined,
glFunded: isGlDebit,
previewDdNumbers: isSlip ? this.previewDdNumbersFrom(leaflet.ddNumber, this.ddCountFor(row)) : undefined
}
});
@@ -2555,6 +2761,31 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// review the entered details, and no way to abandon them either.
dialogRef.afterClosed().subscribe((result) => {
if (!result) return;
// A slip records its lines instead of a single beneficiary, and takes the row's AMOUNT and
// CHARGES with it: from here on those two are the sum of the lines, shown read-only, so a
// clerk cannot leave a row total behind that disagrees with what the drafts add up to.
if (result.lines) {
this.clearDdCapture(row);
row.ddLines = result.lines;
row.ddInventoryId = leaflet.inventoryId;
row.ddFundingAccountId = fundingAccountId!;
// Null on a GL slip, which is raised against no member at all.
row.ddFundingClientId = fundingClientId ?? undefined;
row.amount = this.ddFaceValueFor(row);
row.ddCharges = this.ddChargesFor(row);
this.showAlert('success', 'Slip Details Captured', [
`${result.lines.length} demand drafts captured in favour of ${result.lines
.map((l: DdSlipLine) => l.beneficiaryName)
.join(', ')}.`,
`₹${this.ddTotalPayableFor(row)} will be debited from ${isGlDebit ? 'GL account ' : ''}${row.acNo} as a single ${
isGlDebit ? 'journal entry' : 'transaction'
} once a checker approves.`,
'DD numbers are allocated on submission. Press Save to send this slip for approval.'
]);
return;
}
row.ddDetails = {
leafletId: leaflet.id,
ddNumber: leaflet.ddNumber,
@@ -2607,7 +2838,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
fundingAccountId: number,
fundingClientId: number | null
): void {
const d = new Date();
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
const isGl = row.acTyp === 'GL';
@@ -2658,7 +2889,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
* got none has no way of telling which row was missing.
*/
private saveDdRows(ddRows: TxnRow[]): void {
const unfilled = ddRows.filter((r) => !r.ddDetails);
const unfilled = ddRows.filter((r) => !r.ddDetails && !r.ddLines?.length);
if (unfilled.length > 0) {
this.showAlert('error', 'DD Details Not Entered', [
unfilled.length === 1
@@ -2670,6 +2901,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
for (const row of ddRows) {
// A slip goes to the batch endpoint, which allocates its leaflets and posts one debit for the
// whole run. Sending its lines one at a time through the single-DD endpoint would debit the
// member once per draft — the very thing a slip exists to avoid.
if (row.ddLines?.length) {
this.submitDdSlip(row);
continue;
}
const details = row.ddDetails!;
const leaflet = { id: details.leafletId, ddNumber: details.ddNumber };
if (this.isDdAccountDebitRow(row)) {
@@ -2697,6 +2936,99 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
}
/**
* Submits a multi-DD slip: several drafts raised together against one debit of the member's
* account.
*
* Maker-checker like the single-draft flow, and nothing is debited here. On approval the server
* withdraws the combined face value, charges and GST of every line as ONE transaction and posts
* one journal entry against it, then issues each draft — so the member's passbook carries a single
* entry naming all the DD numbers rather than one entry per draft. The checker approves or rejects
* the slip whole; there is no approving four drafts out of five.
*
* The leaflets are not chosen here. The server allocates the run inside its own locked transaction
* and returns which number went to which line, which is the only point at which the numbers are
* real — two clerks submitting at the same moment can therefore never be handed the same one.
*/
private submitDdSlip(row: TxnRow): void {
const lines = row.ddLines ?? [];
const inventoryId = row.ddInventoryId;
const fundingAccountId = row.ddFundingAccountId;
const fundingClientId = row.ddFundingClientId;
const isGl = row.acTyp === 'GL';
// Re-checked at Save, not just when the details were captured: the row's TYPE, AC.TP or account
// number can all be edited afterwards, and a slip that has drifted onto a cash row is one the
// batch endpoint refuses outright. A GL slip is raised against no member, so it carries no
// client id and none is required of it — the same asymmetry as the single-draft GL path.
if (!inventoryId || !fundingAccountId || (!isGl && !fundingClientId) || !this.canRowBeMultiDd(row)) {
this.showAlert('error', 'Slip Cannot Be Submitted', [
'This row has changed since its drafts were captured, so the slip no longer matches it.',
'Open "Fill DD Details" again and re-enter the drafts.'
]);
return;
}
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
// The funding source goes in exactly one of savingsAccountId, currentAccountId or glAccountId,
// as the single-draft path does: which one is set is what decides the debit leg on approval.
const payload = {
clientId: isGl ? undefined : fundingClientId,
issuingBranchId: row.br,
issuedDate,
paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
currentAccountId: !isGl && isCurrent ? fundingAccountId : undefined,
glAccountId: isGl ? fundingAccountId : undefined,
lines: lines.map((l) => ({
beneficiaryName: l.beneficiaryName,
payableAtBranch: l.payableAtBranch,
payableAtCity: l.payableAtCity,
faceValue: Number(l.faceValue),
chargesAmount: Number(l.chargesAmount) || 0
}))
};
const totalDebited = this.ddTotalPayableFor(row);
const accountNo = row.acNo;
this.saving = true;
this.sponsorDDService.submitIssueRequestBatch(inventoryId, payload).subscribe({
next: (result: any) => {
this.saving = false;
// The allocated numbers, named back to the clerk. Until this response they were provisional,
// and these are what the customer's drafts will actually carry.
const allocated = (result?.lines ?? [])
.map((l: any) => `${l.ddNumber} · ${l.beneficiaryName} · ₹${l.faceValue}`)
.join('\n');
this.showAlert('success', 'DD Slip Submitted', [
`Slip of ${lines.length} demand drafts submitted successfully.`,
allocated || `${lines.length} drafts allocated.`,
`₹${totalDebited} will be debited from ${accountNo} as a single transaction on approval.`,
'Sent to Manager / Head Cashier for maker-checker approval of the whole slip.'
]);
row.amount = null;
row.ddCharges = null;
row.description = '';
row.ddCount = 1;
// Cleared with the row, as the single-draft paths do: leaving the lines behind would let a
// second Save raise the same slip again against leaflets already allocated.
this.clearDdCapture(row);
},
error: (err) => {
this.saving = false;
this.showAlert('error', 'DD Slip Submission Failed', [
err.error?.message || err.message,
'No drafts were issued and nothing has been debited — the whole slip was refused together.'
]);
}
});
}
private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void {
// Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked
// right but matched nothing, so every cash DD went in with no breakdown at all and its money
@@ -2737,7 +3069,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const d = new Date();
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const payload = {
@@ -3060,7 +3392,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
@@ -3094,6 +3426,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
meetingDate: shareRow.meetingDate,
description: shareRow.description || null,
settlementPaymentTypeId: savingsRow.paymentTypeId,
settlementMode: 'SAVINGS',
settlementRowSnapshot
};
@@ -3124,11 +3457,60 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
);
}
/**
* Shows the confirm dialog with the same checklist the Manager will see when approving this
* request — fetched live from the backend (`previewValidate`) rather than re-implemented here,
* so it can never show the clerk something different from what the approver actually sees. A
* failing check is shown, not blocked: approve() enforces its own hard rules separately (e.g.
* Share Advance Collection applied), this list is advisory so problems surface to whoever can
* still fix them, before a manager's time is spent on a request that was always going to fail.
*/
private showShareWithdrawalConfirm(
payload: Record<string, unknown>,
baseLines: string[],
onConfirm: () => void
): void {
this.clerkShareWithdrawalService
.previewValidate(payload)
.pipe(
// A request that never resolves (slow network, server busy) must not leave the Submit
// button looking dead — the dialog still has to open, just without the checklist.
timeout(5000),
catchError(() => of({ __previewFailed: true }))
)
.subscribe((res: any) => {
const checks: { label: string; pass: boolean; detail: string }[] = res?.checks ?? [];
// A failed call (most likely the backend not yet carrying the /preview-validate endpoint)
// says so explicitly rather than just omitting the section — a silently empty checklist
// looks identical to "everything passed, nothing to show", which is the one reading this
// must never give.
const checkLines = res?.__previewFailed ? [
'',
'⚠ Validation checklist unavailable right now — the request can still be submitted.'
] : checks.length ? [
'',
'Validation Checks (same as Manager approval):',
...checks.map((c) => `${c.pass ? '✓' : '⚠'} ${c.label}: ${c.detail}`)
] : [];
// baseLines ends with a blank line followed by the "Pending Manager approval…" footer
// sentence, which must stay last so the modal renders it as a footnote rather than a
// key/value row — so the checklist is spliced in just before that pair, not appended after.
const footerStart =
baseLines.length >= 2 && baseLines[baseLines.length - 2] === '' ? baseLines.length - 2 : baseLines.length;
const lines = [
...baseLines.slice(0, footerStart),
...checkLines,
...baseLines.slice(footerStart)
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', lines, 'Submit for Approval', onConfirm);
});
}
/**
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
@@ -3181,7 +3563,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
@@ -3240,7 +3622,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.showShareWithdrawalConfirm(payload, confirmLines, () =>
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
);
}
@@ -3842,7 +4224,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
): void {
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
const todayStr = (() => {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
})();
@@ -4218,7 +4600,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
get todayIso(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -4740,7 +5122,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.sameDayChequeClearing = 0;
return;
}
const today = new Date();
const today = businessToday();
const sameDay = (d: any) => {
if (!d) return false;
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
@@ -4864,7 +5246,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.showAlert('error', 'Invalid Account Type', [
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
'Only Savings (SD) and Current (CA) accounts are permitted.',
'GL, FD, RD, SHR accounts are not allowed — use Disbursement/Collection instead.'
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
'RD installments are paid through a cashier Receipt or Auto Debit.'
]);
return;
}
@@ -4918,6 +5301,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rows.length >= 2 &&
this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer')
) {
// A Recurring Deposit is never paid this way: these requests post straight into the account, so the
// installment schedule never hears of the money -- installments stay unpaid, Auto Debit collects them
// again and the maturity amount comes out wrong. RD installments come in through the cashier's
// Receipt or the RD's own Auto Debit.
const rdRows = this.rows.filter((r) => r.acTyp === 'RD');
if (rdRows.length > 0) {
this.showAlert('error', 'Recurring Deposit Not Allowed', [
`RD account(s) ${rdRows.map((r) => r.acNo).join(', ')} cannot be used in a Disbursement, Collection or Fund Transfer.`,
'Pay RD installments through a cashier Receipt, or by Auto Debit from the linked savings account.'
]);
return;
}
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
@@ -5473,13 +5869,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Cashier deposit (savings/current/GL) → route through maker-checker (create PENDING request, no direct credit)
// Cashier deposit (savings/current/FD/RD/GL) → route through maker-checker (create PENDING request,
// no direct credit). FD and RD are their own account-type codes (see creditEntryAccountType()) and
// belong here just as much as a plain savings deposit -- a cashier crediting an FD/RD must not be
// able to move money in without a manager's approval either.
// Use the dialog-confirmed amount — for cash that is the denomination total
if (
!this.isClerk &&
!this.reviewMode &&
isDeposit &&
(row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')
(row.acTyp === 'SD' ||
row.acTyp === 'CA' ||
row.acTyp === 'CB' ||
row.acTyp === 'FD' ||
row.acTyp === 'RD' ||
row.acTyp === 'GL')
) {
this.submitCashierDepositRequest(row, this.confirmData?.amount ?? amount);
return;
@@ -5620,7 +6024,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const payload: any = {
@@ -5702,7 +6106,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.editRequest = req;
}
const acTyp = 'SD';
// Same bug, same fix as the token-driven withdrawal-slip lookup above (see its comment): the
// request carries its own real account_type (SAVINGS, CURRENT, FIXED_DEPOSIT or
// RECURRING_DEPOSIT), and hardcoding 'SD' here searched every non-savings account on the
// wrong endpoint, so reviewing a Current-account withdrawal always failed with "Account not
// found".
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
const row = this.rows[0];
row.acNo = req.account_no ?? '';
row.acTyp = acTyp;
@@ -5810,7 +6219,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the withdrawal-request payload from the current row + loaded account. */
private buildWithdrawalPayload(row: TxnRow, amount: number): any {
const today = new Date();
const today = businessToday();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
@@ -5915,7 +6324,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the deposit-request payload from the current row + loaded account. */
private buildDepositPayload(row: TxnRow, amount: number): any {
const today = new Date();
const today = businessToday();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
// Send denominations only for cash payments — stale counts on a cheque
@@ -6026,7 +6435,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
@@ -16,6 +16,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -67,7 +68,7 @@ export class VaultPositionComponent implements OnInit {
errorMsg = '';
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
@@ -13,6 +13,7 @@ import { ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Which side of the register a screen shows.
@@ -227,7 +228,7 @@ export class VoucherReportComponent implements OnInit {
}
private todayIso(): string {
const now = new Date();
const now = businessToday();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
@@ -129,6 +129,31 @@
<span>* Date of Meeting</span>
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
</label>
<label class="inv-form-field">
<span>* Settlement Destination</span>
@if (!isReview) {
<select [ngModel]="settlementMode" (ngModelChange)="onSettlementModeChange($event)">
<option value="SAVINGS">Member's Linked Savings Account</option>
<option value="GL">GL Account</option>
</select>
} @else {
<input
type="text"
[value]="reviewRequest?.settlement_mode === 'GL' ? 'GL Account' : 'Linked Savings Account'"
readonly
/>
}
</label>
@if (!isReview && settlementMode === 'GL') {
<label class="inv-form-field">
<span>* Settlement GL Account</span>
<mifosx-gl-account-selector
[inputFormControl]="toGlAccountControl"
[glAccountList]="glAccountsData"
[required]="true"
></mifosx-gl-account-selector>
</label>
}
<label class="inv-form-field">
<span>* Settlement Payment Type</span>
@if (!isReview) {
@@ -386,14 +411,25 @@
<td>Date of Meeting</td>
<td>{{ meetingDate }}</td>
</tr>
<tr>
<td>Settlement Destination</td>
<td>{{ settlementMode === 'GL' ? 'GL Account' : 'Linked Savings Account' }}</td>
</tr>
@if (settlementMode === 'GL') {
<tr>
<td>Settlement GL Account</td>
<td>{{ selectedSettlementGlName }}</td>
</tr>
} @else {
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
}
<tr>
<td>Settlement Payment Type</td>
<td>{{ settlementPaymentTypeName }}</td>
</tr>
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
</tbody>
</table>
@@ -8,7 +8,7 @@
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FormsModule, UntypedFormControl } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
@@ -19,6 +19,9 @@ import { SharesService } from '../../shares/shares.service';
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
import { CashierService } from '../../cashier/cashier.service';
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
import { GlAccountSelectorComponent } from '../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { AccountingService } from '../../accounting/accounting.service';
import { GLAccount } from '../../shared/models/general.model';
export type ShareWdMode = 'create' | 'review';
@@ -51,7 +54,8 @@ interface ActivityEntry {
RouterModule,
MatIconModule,
MatSnackBarModule,
SearchSelectComponent
SearchSelectComponent,
GlAccountSelectorComponent
]
})
export class ShareWithdrawalRequestComponent implements OnInit {
@@ -60,6 +64,7 @@ export class ShareWithdrawalRequestComponent implements OnInit {
private sharesService = inject(SharesService);
private wdService = inject(ClerkShareWithdrawalService);
private cashierService = inject(CashierService);
private accountingService = inject(AccountingService);
private snackBar = inject(MatSnackBar);
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
@@ -91,6 +96,17 @@ export class ShareWithdrawalRequestComponent implements OnInit {
paymentTypes: any[] = [];
settlementPaymentTypeId: number | null = null;
/** Where the redeemed value goes. 'SAVINGS' (the long-standing default) credits the member's
* linked savings account. 'GL' lets the clerk pick any postable GL account directly — same
* option the Cashier's Transaction Entry screen already offers on its CR row, now available
* here too so both entry points behave the same way. Naming the product's own Share Sundry GL
* here is still fine: the backend recognises it and books straight to Sundry with no further
* settlement leg (see ClerkShareWithdrawalRequestApiResource#submit). */
settlementMode: 'SAVINGS' | 'GL' = 'SAVINGS';
settlementGlAccountId: number | null = null;
glAccountsData: GLAccount[] = [];
toGlAccountControl = new UntypedFormControl('');
activityLog: ActivityEntry[] = [];
reviewRequest: any = null;
showRejectDialog = false;
@@ -132,6 +148,17 @@ export class ShareWithdrawalRequestComponent implements OnInit {
return this.selectedSettlementPaymentType?.name || '—';
}
/** The chosen GL account's display name, for the confirm dialog and the checklist. Falls back to
* the raw id while the GL account list is still loading. */
get selectedSettlementGlName(): string {
if (!this.settlementGlAccountId) {
return '—';
}
return (
this.glAccountsData.find((g) => g.id === this.settlementGlAccountId)?.name || `GL #${this.settlementGlAccountId}`
);
}
get isFormValid(): boolean {
if (!this.account) return false;
if (!this.requestedShares || this.requestedShares <= 0) return false;
@@ -141,6 +168,7 @@ export class ShareWithdrawalRequestComponent implements OnInit {
if (!this.resolutionNumber.trim()) return false;
if (!this.meetingDate) return false;
if (!this.settlementPaymentTypeId) return false;
if (this.settlementMode === 'GL' && !this.settlementGlAccountId) return false;
return true;
}
@@ -160,11 +188,18 @@ export class ShareWithdrawalRequestComponent implements OnInit {
pass: shares > 0 && shares <= held,
detail: `${shares} of ${held} shares held`
},
{
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
this.settlementMode === 'GL'
? {
label: 'Settlement GL Account selected',
pass: !!this.settlementGlAccountId,
detail: this.settlementGlAccountId ? this.selectedSettlementGlName : 'Missing'
}
: {
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail:
this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
{
label: 'Settlement Payment Type selected (non-cash)',
pass: this.settlementPaymentTypeName !== '—',
@@ -214,11 +249,37 @@ export class ShareWithdrawalRequestComponent implements OnInit {
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
this.toGlAccountControl.valueChanges.subscribe((id: any) => {
this.settlementGlAccountId = id ? Number(id) : null;
});
if (this.requestId && this.mode === 'review') {
this.loadReviewRequest(this.requestId);
}
}
/** Flips the settlement destination. Switching away from GL clears whatever was picked so a
* stale GL id can never ride along on a SAVINGS submission. */
onSettlementModeChange(mode: 'SAVINGS' | 'GL'): void {
this.settlementMode = mode;
if (mode === 'GL') {
this.loadGlAccounts();
} else {
this.settlementGlAccountId = null;
this.toGlAccountControl.setValue('');
}
}
/** Fetched on first use rather than with the page, since most withdrawals settle to savings. */
private loadGlAccounts(): void {
if (this.glAccountsData.length) {
return;
}
this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
this.glAccountsData = accounts ?? [];
});
}
onAccountSelected(accountId: string): void {
this.selectedAccountId = accountId;
if (!accountId) {
@@ -246,6 +307,11 @@ export class ShareWithdrawalRequestComponent implements OnInit {
this.resolutionNumber = req.resolution_number ?? '';
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
this.description = req.description ?? '';
this.settlementMode = req.settlement_mode === 'GL' ? 'GL' : 'SAVINGS';
this.settlementGlAccountId = req.settlement_gl_account_id ? Number(req.settlement_gl_account_id) : null;
if (this.settlementMode === 'GL') {
this.loadGlAccounts();
}
if (req.activity_log) {
try {
@@ -289,7 +355,9 @@ export class ShareWithdrawalRequestComponent implements OnInit {
resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.meetingDate,
description: this.description,
settlementPaymentTypeId: this.settlementPaymentTypeId
settlementPaymentTypeId: this.settlementPaymentTypeId,
settlementMode: this.settlementMode,
...(this.settlementMode === 'GL' ? { settlementGlAccountId: this.settlementGlAccountId } : {})
};
this.wdService.submit(payload).subscribe({
next: () => {
@@ -643,8 +643,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.createClientForm.get('degreeQualificationId')?.setValue('');
}
});
// Fetch all constitution options from code ID 17
this.clientService.getCodeValuesById(17).subscribe((options) => {
// Constitution options by code NAME, never by id: code ids differ per tenant (on abc_bank id 17 is the empty
// ClientClassification code and Constitution is id 24), so a hardcoded id left the dropdown empty.
this.clientService.getCodeValuesByName('Constitution').subscribe((options) => {
if (options?.length) {
this.constitutionOptions = options;
// Sort master by value length descending so longer/more-specific keys match first
@@ -1321,6 +1321,11 @@
kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched
) {
<mat-error>Only letters, numbers, hyphens and slashes allowed</mat-error>
} @else if (
kycForm.get('kycDocumentNumber')?.hasError('aadhaarLast4Mismatch') &&
kycForm.get('kycDocumentNumber')?.touched
) {
<mat-error>Last 4 digits do not match the Aadhaar number entered above</mat-error>
}
</mat-form-field>
@@ -8,7 +8,14 @@
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { forkJoin, catchError, of } from 'rxjs';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import {
UntypedFormBuilder,
UntypedFormGroup,
Validators,
FormsModule,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import {
MatDialog,
MAT_DIALOG_DATA,
@@ -567,6 +574,28 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Existing signature document ID (for re-upload/delete) */
existingSignatureDocId: string = '';
/**
* Cross-field check for the OVD "Document Number" field (kycDocumentNumber): when Aadhaar Card
* is the selected KYC document, its value must identify the same Aadhaar as the aadhaarNumber
* field captured earlier in this step - the last 4 digits are what's compared, since
* aadhaarNumber may hold either the raw 12-digit number or a masked value (masking only ever
* obscures the first 8 digits - see aadhaarMasked in the kycData getter below - so the last 4
* characters are always the real digits either way). Not applicable to any other document type,
* where kycDocumentNumber is unrelated to Aadhaar entirely.
*/
private aadhaarLast4MatchValidator = (control: AbstractControl): ValidationErrors | null => {
if (this.selectedKycDoc?.code !== 'AADHAAR') {
return null;
}
const docNumber = (control.value || '').toString().trim();
const aadhaarValue = (control.parent?.get('aadhaarNumber')?.value || '').toString().trim();
if (docNumber.length < 4 || aadhaarValue.length < 4) {
// Required/pattern validators already flag an empty or too-short value on their own.
return null;
}
return docNumber.slice(-4) === aadhaarValue.slice(-4) ? null : { aadhaarLast4Mismatch: true };
};
/** KYC Form */
kycForm: UntypedFormGroup = this.formBuilder.group({
hasPan: [true],
@@ -616,7 +645,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
[
Validators.required,
Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/),
Validators.maxLength(30)
Validators.maxLength(30),
this.aadhaarLast4MatchValidator
]
],
kycDocIssueDate: [''],
@@ -739,6 +769,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycForm.get('ecgcSalFlag')?.disable();
this.kycForm.get('dormantFlag')?.disable();
}
// aadhaarLast4MatchValidator (on kycDocumentNumber) reads this control's value directly rather
// than through its own valueChanges, so a re-check has to be triggered explicitly whenever the
// Aadhaar field itself changes - otherwise fixing aadhaarNumber after kycDocumentNumber was
// already entered would leave a stale mismatch error in place.
this.kycForm.get('aadhaarNumber')?.valueChanges.subscribe(() => {
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
});
}
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
@@ -1009,6 +1047,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Reset uploads + merged preview when document type changes
this.kycDocFiles = { front: null, back: null };
this.kycDocMergedPreview = null;
// aadhaarLast4MatchValidator only applies while Aadhaar Card is selected - re-check so switching
// to/away from it immediately picks up (or clears) the mismatch error.
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
}
isKycDocSelected(proof: ProofType): boolean {
@@ -1189,6 +1230,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Sync chip-picker selection with the prefilled document name
if (data.kycDocumentName) {
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
// patchValue above ran kycDocumentNumber's validators before selectedKycDoc was known,
// so aadhaarLast4MatchValidator needs a fresh check now that it is.
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
}
// Set verification statuses from existing data
if (data.panVerified) {
@@ -61,6 +61,7 @@ import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
import { tdsConfigResolver } from '../letter-mgmt/certificates/form-15gh/tds-config.service';
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
import { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component';
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
@@ -198,6 +199,7 @@ const routes: Routes = [
{
path: 'form121',
component: Form121TabComponent,
resolve: { tdsConfig: tdsConfigResolver },
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
},
{
@@ -202,9 +202,14 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span>
@if (existing.ADDRESS_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) {
@if (
(existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) && isImage('ADDRESS_PROOF')
) {
<div class="ckyc-existing__thumbs">
@if (existing.ADDRESS_PROOF.previewUrl) {
<img
@@ -222,6 +227,17 @@
}
</div>
}
@if (existing.ADDRESS_PROOF.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('ADDRESS_PROOF')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-alt"></fa-icon>
<span>{{ existing.ADDRESS_PROOF.name }}</span>
@@ -299,9 +315,12 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span>
@if (existing.PHOTO_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) {
@if ((existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) && isImage('PHOTO_PROOF')) {
<div class="ckyc-existing__thumbs">
@if (existing.PHOTO_PROOF.previewUrl) {
<img
@@ -319,6 +338,17 @@
}
</div>
}
@if (existing.PHOTO_PROOF.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('PHOTO_PROOF')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="id-card"></fa-icon>
<span>{{ existing.PHOTO_PROOF.name }}</span>
@@ -393,11 +423,25 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_IMAGE.source }}</span>
@if (existing.CLIENT_IMAGE.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.CLIENT_IMAGE.previewUrl) {
@if (existing.CLIENT_IMAGE.previewUrl && isImage('CLIENT_IMAGE')) {
<img [src]="existing.CLIENT_IMAGE.previewUrl" alt="Client photo" class="ckyc-existing__thumb" />
}
@if (existing.CLIENT_IMAGE.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('CLIENT_IMAGE')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<button
mat-stroked-button
type="button"
@@ -448,15 +492,29 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_SIGNATURE.source }}</span>
@if (existing.CLIENT_SIGNATURE.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.CLIENT_SIGNATURE.previewUrl) {
@if (existing.CLIENT_SIGNATURE.previewUrl && isImage('CLIENT_SIGNATURE')) {
<img
[src]="existing.CLIENT_SIGNATURE.previewUrl"
alt="Client signature"
class="ckyc-existing__thumb ckyc-existing__thumb--wide"
/>
}
@if (existing.CLIENT_SIGNATURE.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('CLIENT_SIGNATURE')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-signature"></fa-icon>
<span>{{ existing.CLIENT_SIGNATURE.name }}</span>
@@ -623,6 +623,35 @@
}
}
/* Opens the uploaded artefact full size in a new tab. Sits directly under the thumbnail, and is
the only way to inspect a PDF - which cannot render as an inline <img>. */
.ckyc-existing__view {
align-self: flex-start;
color: #1d6fd0;
border-color: #bcd8f5;
background: #fff;
font-size: 13px;
font-weight: 600;
&:hover {
background: #eef6ff;
}
}
/* Shown when a document row exists but its file could not be fetched from the content store,
so "Already available" is not silently misleading. */
.ckyc-existing__warn {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 10px;
background: #fdecec;
border: 1px solid #f3c2c2;
color: #a12b2b;
font-size: 11px;
font-weight: 600;
}
.ckyc-existing {
display: flex;
flex-direction: column;
@@ -36,11 +36,41 @@ interface ExistingArtefact {
* already writes/reads for address proof; now shared by the photo/identity proof too. */
description?: string;
fileCount?: number;
/** Set when the stored file could not be fetched - the row exists but its content is gone. */
previewUnavailable?: boolean;
/** Stored MIME type, so the card knows whether it can render an inline thumbnail. */
mimeType?: string;
}
type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF';
type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE';
/**
* Client-side upload guards, kept deliberately in step with the backend so a file that passes here
* is not rejected server-side:
* - 5 MB matches `spring.servlet.multipart.max-file-size`
* - the extension list is the intersection of the backend `fineract.content.regex-whitelist`
* with what these cards can actually render.
* The dialog's `accept=` is only a file-picker hint and is trivially bypassed, so the real check
* runs here, on the file the dialog hands back, before anything is sent.
*/
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const IMAGE_EXTENSIONS = [
'png',
'jpeg',
'jpg'
];
const DOCUMENT_EXTENSIONS = [
...IMAGE_EXTENSIONS,
'pdf'
];
/** Cards that render an inline <img>, so a PDF would not display in them. */
const IMAGE_ONLY_KINDS: CkycDocKind[] = [
'CLIENT_IMAGE',
'CLIENT_SIGNATURE'
];
@Component({
selector: 'mifosx-ckyc-tab',
templateUrl: './ckyc-tab.component.html',
@@ -150,6 +180,11 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.route.parent.params.subscribe((params: any) => {
this.clientId = params['clientId'];
// Angular reuses this component when only the clientId in the route changes, and
// loadExistingArtefacts() only ever *assigns* what it finds - it has no "not found" branch.
// Without clearing first, a client with no photo (or no signature, or no proof) keeps showing
// the previous client's card.
this.resetArtefactState();
this.loadClientInfo();
this.loadAccounts();
this.loadCkycData();
@@ -157,6 +192,46 @@ export class CkycTabComponent implements OnInit, OnDestroy {
});
}
/**
* Returns every per-client card to its empty state, so nothing from the client just viewed can
* survive into the next one. Covers all four cards plus the details staged for an upload - a file
* or document number left over from another client must never be submitted against this one.
*/
private resetArtefactState(): void {
this.revokePreviewUrls();
this.existing = {
ADDRESS_PROOF: { present: false },
PHOTO_PROOF: { present: false },
CLIENT_IMAGE: { present: false },
CLIENT_SIGNATURE: { present: false }
};
this.selectedProofType = { ADDRESS_PROOF: null, PHOTO_PROOF: null };
this.docFiles = {
ADDRESS_PROOF: { front: null, back: null },
PHOTO_PROOF: { front: null, back: null }
};
this.documentDetails = {
ADDRESS_PROOF: { documentKey: '', issueDate: null, expiryDate: null },
PHOTO_PROOF: { documentKey: '', issueDate: null, expiryDate: null }
};
this.clientImageFile = null;
this.clientSignatureFile = null;
this.uploadErrors = {
ADDRESS_PROOF: null,
PHOTO_PROOF: null,
CLIENT_IMAGE: null,
CLIENT_SIGNATURE: null
};
}
/** Frees the blob URLs held by the current cards before their references are dropped. */
private revokePreviewUrls(): void {
Object.values(this.existing).forEach((e) => {
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
});
}
private loadClientInfo(): void {
this.route.parent.data.subscribe((data: any) => {
const cv = data?.clientViewData;
@@ -280,19 +355,29 @@ export class CkycTabComponent implements OnInit, OnDestroy {
present: true,
source: 'Client registration',
name: signatureDoc.fileName || 'Client signature',
documentId: signatureDoc.id
documentId: signatureDoc.id,
mimeType: signatureDoc.type
};
// Fetch blob for preview
// Same endpoint getClientSignatureImage() uses, but opted out of the global error alert:
// a m_document row whose file is no longer in the content store returns 500, and the card
// below reports that itself. Without `silent` the interceptor pops an "Internal Server
// Error" modal on every page load, for a failure the user cannot act on.
this.clientsService
.getClientSignatureImage(this.clientId, signatureDoc.id)
.downloadClientDocument(this.clientId, String(signatureDoc.id), true)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
if (blob) {
if (blob && blob.size > 0) {
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
} else {
// A 200 with an empty body still means there is nothing to show. Without this the
// card would claim "Already available" with no thumbnail and no explanation.
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
},
error: () => undefined
error: () => {
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
});
}
@@ -364,6 +449,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
const list = Array.isArray(docs) ? docs : [];
if (!list.length) return;
this.existing[kind].fileCount = list.length;
this.existing[kind].previewUnavailable = false;
const byNewest = (a: any, b: any) => b.id - a.id;
const label = (d: any) => (d?.name || d?.fileName || '').toString();
@@ -372,6 +458,9 @@ export class CkycTabComponent implements OnInit, OnDestroy {
[...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] ||
[...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0];
// A proof can be scanned to PDF, which no <img> can render - remember what was stored so the
// card offers "View document" instead of a broken thumbnail.
if (front) this.existing[kind].mimeType = front.type;
if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front');
if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back');
});
@@ -388,12 +477,19 @@ export class CkycTabComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
if (!blob) return;
if (!blob || blob.size === 0) {
this.existing[kind].previewUnavailable = true;
return;
}
const url = URL.createObjectURL(blob);
if (slot === 'front') this.existing[kind].previewUrl = url;
else this.existing[kind].previewUrlBack = url;
},
error: () => undefined
// The row exists but its file is gone from the content store. Saying so on the card beats
// showing "Already available" next to a blank space.
error: () => {
this.existing[kind].previewUnavailable = true;
}
});
}
@@ -452,6 +548,18 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => {
if (!result) return;
// The dialog's `accept=` filter is only a hint to the file picker and is trivially bypassed,
// so the size/type guard runs here on what actually came back. Catching it now turns a raw
// server rejection into a message on the card.
const rejection =
this.validationError(result.front, kind) ?? (result.back ? this.validationError(result.back, kind) : null);
if (rejection) {
this.uploadErrors[kind] = rejection;
return;
}
this.uploadErrors[kind] = null;
if (isProofKind) {
const proofKind = kind as ProofKind;
this.selectedProofType[proofKind] = result.proofType || null;
@@ -470,6 +578,66 @@ export class CkycTabComponent implements OnInit, OnDestroy {
});
}
/** Extensions a given card can both store and render. */
private allowedExtensions(kind: CkycDocKind): string[] {
return IMAGE_ONLY_KINDS.includes(kind) ? IMAGE_EXTENSIONS : DOCUMENT_EXTENSIONS;
}
/**
* Rejects a file the backend would refuse (or the card could not render) before it is uploaded,
* so the user gets a clear message instead of a raw server error. Returns null when the file is
* acceptable.
*/
private validationError(file: File | null | undefined, kind: CkycDocKind): string | null {
if (!file) {
return null;
}
const allowed = this.allowedExtensions(kind);
const extension = (file.name.split('.').pop() || '').toLowerCase();
if (!allowed.includes(extension)) {
return `Unsupported file type ".${extension}". Allowed: ${allowed.map((e) => e.toUpperCase()).join(', ')}.`;
}
if (file.size > MAX_UPLOAD_BYTES) {
const mb = (file.size / (1024 * 1024)).toFixed(1);
return `File is ${mb} MB. The maximum allowed size is 5 MB.`;
}
if (file.size === 0) {
return 'The selected file is empty.';
}
return null;
}
/**
* True when the stored file can be shown as an inline thumbnail. A PDF cannot, so its card falls
* back to the "View document" button alone rather than rendering a broken image.
*/
isImage(kind: CkycDocKind): boolean {
const entry = this.existing[kind];
// The profile image arrives as a `data:image/...;base64,` URL, which declares its own type -
// it has neither a stored MIME type nor a file extension for the checks below to read.
if (entry?.previewUrl?.startsWith('data:')) {
return entry.previewUrl.startsWith('data:image/');
}
if (entry?.mimeType) {
return entry.mimeType.startsWith('image/');
}
// Older rows can have no stored MIME type - fall back to the file extension.
const extension = (entry?.name?.split('.').pop() || '').toLowerCase();
return IMAGE_EXTENSIONS.includes(extension);
}
/**
* Opens the uploaded document full size in a new tab. Uses the blob already fetched for the
* thumbnail, so it works for PDFs too and costs no extra request.
*/
openDocument(kind: CkycDocKind): void {
const url = this.existing[kind]?.previewUrl;
if (url) {
window.open(url, '_blank', 'noopener');
}
}
/** Reads back the "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" convention this tab (and
* client-india-address-step, for address proof) writes into the identifier's description. */
private parseDescriptionDates(description: string | undefined): { issueDate: Date | null; expiryDate: Date | null } {
@@ -533,6 +701,10 @@ export class CkycTabComponent implements OnInit, OnDestroy {
request$.pipe(takeUntil(this.destroy$)).subscribe({
next: () => {
this.uploading[documentType] = false;
// The card is gated on `present`; without this a successful upload left it reading
// "Not uploaded yet". uploadProofDocument() sets the same flag for the proof cards.
this.existing[key].present = true;
this.existing[key].source = this.existing[key].source || 'CKYC upload';
this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' });
this.refreshPreview(documentType);
},
@@ -653,13 +825,22 @@ export class CkycTabComponent implements OnInit, OnDestroy {
if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) {
URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!);
}
// The upload that triggered this refresh is what repairs a previously missing file, so the
// warning has to be cleared before the re-read - otherwise a fixed card keeps the old badge.
this.existing[staleKey].previewUrl = undefined;
this.existing[staleKey].previewUrlBack = undefined;
this.existing[staleKey].previewUnavailable = false;
switch (documentType) {
case 'CLIENT_IMAGE':
this.clientsService
.getClientProfileImage(this.clientId)
.pipe(takeUntil(this.destroy$))
.subscribe((photo: any) => {
if (photo) this.existing.CLIENT_IMAGE.previewUrl = photo;
if (photo) {
this.existing.CLIENT_IMAGE.previewUrl = photo;
} else {
this.existing.CLIENT_IMAGE.previewUnavailable = true;
}
});
break;
case 'CLIENT_SIGNATURE':
@@ -672,11 +853,21 @@ export class CkycTabComponent implements OnInit, OnDestroy {
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (!doc) return;
this.existing.CLIENT_SIGNATURE.documentId = doc.id;
this.existing.CLIENT_SIGNATURE.mimeType = doc.type;
this.clientsService
.getClientSignatureImage(this.clientId, doc.id)
.downloadClientDocument(this.clientId, String(doc.id), true)
.pipe(takeUntil(this.destroy$))
.subscribe((blob: any) => {
if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
.subscribe({
next: (blob: any) => {
if (blob && blob.size > 0) {
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
} else {
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
},
error: () => {
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
});
});
break;
@@ -690,10 +881,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
// Release object URLs
Object.values(this.existing).forEach((e) => {
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
});
this.revokePreviewUrls();
}
}
@@ -55,7 +55,10 @@
<div class="f121__note">
<mat-icon>info_outline</mat-icon>
<div>
@if (!dateOfBirth) {
@if (configMissing) {
TDS rates for FY {{ fy }} are not configured, so the tax cannot be computed. Set them up under Organization
› TDS Configuration.
} @else if (!dateOfBirth) {
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
Data tab.
} @else {
@@ -113,8 +116,8 @@
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
deposits. Turn it off on those accounts, or tax will keep being deducted.
A declaration is on record for this year, but one or more deposits still carry a tax group. Those accounts
deduct on their own and ignore the declaration — remove the tax group from them.
</div>
</div>
}
@@ -189,20 +192,90 @@
}
</span>
</div>
@if (declarationOnRecord && declarationFy) {
@if (declarationRecord; as d) {
<div class="f121__kv-row">
<span>Declared for</span>
<span>FY {{ declarationFy }}</span>
<span>FY {{ d.fy }} · Form {{ d.formType }}</span>
</div>
}
@if (declarationOnRecord && !acceptanceDateKnown) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Acceptance date</span>
<div class="f121__kv-row">
<span>Accepted on</span>
<span>
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
the figures below.
{{ d.acceptedOn | date: 'd MMM y' }} — no TDS on interest credited from this day to 31 March. Interest
credited before it is taxed as usual.
</span>
</div>
@if (d.uin) {
<div class="f121__kv-row">
<span>UIN</span>
<span class="f121__mono">{{ d.uin }}</span>
</div>
}
<div class="f121__kv-row">
<span></span>
<span>
<button type="button" class="f121__btn f121__btn--warn" (click)="revokeDeclaration()">
Withdraw declaration
</button>
</span>
</div>
} @else if (fy && !configMissing) {
<div class="f121__kv-row">
<span></span>
<span>
@if (!showDeclarationForm) {
<button type="button" class="f121__btn" (click)="openDeclarationForm()" [disabled]="!panWellFormed">
Record {{ formLabel }} for FY {{ fy }}
</button>
@if (!panWellFormed) {
<div class="muted">A valid PAN must be on the KYC first — a declaration without one is void.</div>
}
}
</span>
</div>
@if (showDeclarationForm) {
<div class="f121__decl-form">
<label>
<span>Accepted on *</span>
<input type="date" [(ngModel)]="newDeclaration.acceptedOn" />
</label>
<label>
<span>UIN</span>
<input type="text" [(ngModel)]="newDeclaration.uin" placeholder="Allotted on acceptance" />
</label>
<label>
<span>Estimated income for the year (₹)</span>
<input type="number" min="0" [(ngModel)]="newDeclaration.estimatedIncome" />
</label>
@if (declarationError) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>{{ declarationError }}</div>
</div>
}
<div class="f121__decl-actions">
<button type="button" class="f121__btn" (click)="showDeclarationForm = false">Cancel</button>
<button
type="button"
class="f121__btn f121__btn--primary"
(click)="saveDeclaration()"
[disabled]="savingDeclaration"
>
{{ savingDeclaration ? 'Saving…' : 'Save declaration' }}
</button>
</div>
</div>
}
}
@for (old of declarations; track old.id) {
@if (old.revoked && old.fy === fy) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Withdrawn</span>
<span>
Form {{ old.formType }} accepted {{ old.acceptedOn | date: 'd MMM y' }}, withdrawn
{{ old.revokedOn | date: 'd MMM y' }} — {{ old.revokeReason }}
</span>
</div>
}
}
@if (eligibility) {
@for (reason of eligibility.reasons; track reason) {
@@ -332,3 +332,61 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 600;
}
/* ── Recording a declaration ── */
.f121__btn {
padding: 6px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&--primary {
border-color: #1d4ed8;
background: #1d4ed8;
color: #fff;
}
&--warn {
border-color: #b45309;
color: #b45309;
}
}
.f121__decl-form {
display: grid;
gap: 10px;
padding: 12px 16px;
label {
display: grid;
gap: 4px;
font-size: 12px;
span {
color: #6b7280;
font-weight: 600;
}
input {
max-width: 280px;
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font: inherit;
}
}
}
.f121__decl-actions {
display: flex;
gap: 8px;
}
@@ -23,6 +23,8 @@ import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { TdsDeclarationRecord, TdsReportsService } from 'app/tds-reports/tds-reports.service';
import { TdsDeductee } from 'app/tds-reports/tds-reports-data';
/** Domain logic */
import {
@@ -111,6 +113,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private savingsService = inject(SavingsService);
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
private tdsService = inject(TdsReportsService);
private destroy$ = new Subject<void>();
@@ -124,23 +127,25 @@ export class Form121TabComponent implements OnInit, OnDestroy {
pan = '';
panWellFormed = false;
// ── Declaration on record ──
declarationOnRecord = false;
// ── Declarations on record ──
/**
* Every declaration the customer has given, each for one year and from the day the branch
* accepted it. The KYC record only ever held a type and a year, which made a September
* declaration shield interest back to April and let last year's form run on.
*/
declarations: TdsDeclarationRecord[] = [];
/**
* The form actually in force for the selected year. From FY 2026-27 a single
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
* than from the payee's age.
*/
declarationForm: DeclarationForm = '121';
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
declarationKind: '15G' | '15H' = '15G';
declarationFy = '';
/**
* The KYC step records only the year, not the date the branch accepted the
* declaration, so it has to be treated as covering the whole year. Surfaced on
* screen rather than hidden — a mid-year acceptance would change the figures.
*/
acceptanceDateKnown = false;
// ── Recording a declaration ──
showDeclarationForm = false;
savingDeclaration = false;
declarationError = '';
newDeclaration = { acceptedOn: '', uin: '', estimatedIncome: null as number | null };
eligibility: EligibilityResult | null = null;
@@ -153,11 +158,13 @@ export class Form121TabComponent implements OnInit, OnDestroy {
schedule: TdsSchedule | null = null;
actualTds = 0;
tdsSaved = 0;
/** The selected year has no entry on Organization › TDS Configuration. */
configMissing = false;
/** Raw account inputs, kept so the year selector can recompute cheaply. */
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
/** Withheld-tax postings by FY, from the ledger. */
private actualTdsByFy = new Map<string, number>();
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow; taxLines: InterestCredit[] })[] = [];
/** This customer's FD/RD interest received in savings, and TDS taken there, per year. */
private platformByFy = new Map<string, TdsDeductee | null>();
ngOnInit(): void {
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
@@ -180,13 +187,15 @@ export class Form121TabComponent implements OnInit, OnDestroy {
forkJoin({
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null))),
declarations: this.tdsService.getDeclarations(Number(this.clientId)).pipe(catchError(() => of([])))
})
.pipe(
takeUntil(this.destroy$),
switchMap(({ client, kyc, accounts }) => {
switchMap(({ client, kyc, accounts, declarations }) => {
this.applyClient(client);
this.applyKyc(kyc);
this.declarations = declarations || [];
const deposits = this.depositAccountsOf(accounts);
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
}),
@@ -210,11 +219,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private applyKyc(kyc: any): void {
this.pan = (kyc?.panNumber ?? '').toUpperCase();
this.panWellFormed = isWellFormedPan(this.pan);
this.declarationOnRecord = !!kyc?.form15Applicable;
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
this.declarationFy = kyc?.form15FY ?? '';
this.acceptanceDateKnown = false;
this.declarationForm =
this.fy && FY_TAX_CONFIG[normaliseFy(this.fy)] ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
}
/** Savings, FD and RD accounts — everything that can credit interest. */
@@ -248,7 +254,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
*/
private ingest(details: any[]): void {
this.accountInputs = [];
this.actualTdsByFy = new Map<string, number>();
this.platformByFy = new Map<string, TdsDeductee | null>();
const fys = new Set<string>();
const clientIdNum = Number(this.clientId);
@@ -269,6 +275,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
};
const credits: InterestCredit[] = [];
const taxLines: InterestCredit[] = [];
let tdsDeducted = 0;
for (const t of d.transactions ?? []) {
@@ -286,8 +293,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
} else if (t.transactionType?.withholdTax) {
const amount = t.amount ?? 0;
tdsDeducted += amount;
const txnFy = fyOf(iso);
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
taxLines.push({ date: iso, amount, accountNo: ref.accountNo });
}
}
@@ -310,12 +316,12 @@ export class Form121TabComponent implements OnInit, OnDestroy {
deducteeClientId: deductee
};
this.accountInputs.push({ ...ref, credits, meta });
this.accountInputs.push({ ...ref, credits, taxLines, meta });
}
this.availableFys = [...fys].sort().reverse();
if (!this.availableFys.includes(this.fy)) {
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
this.fy = this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? '';
}
}
@@ -333,14 +339,39 @@ export class Form121TabComponent implements OnInit, OnDestroy {
}
const year = normaliseFy(this.fy);
this.configMissing = !FY_TAX_CONFIG[year];
// TDS is tracked on FD/RD interest once it reaches savings, and deducted there. Those figures live
// on the platform, not in the deposit's own transactions, so they are fetched once per year and
// the computation waits for them.
if (!this.platformByFy.has(year)) {
this.loadPlatformYear(year);
return;
}
const platform = this.platformByFy.get(year);
for (const a of this.accountInputs) {
if (!a.meta.tdsApplicable) {
continue;
}
const match = platform?.accounts.find((p) => p.accountNo === a.accountNo);
a.credits = match?.credits ?? [];
a.taxLines = match?.deductions ?? [];
}
// Per-account interest for the selected year, for the three lists.
for (const a of this.accountInputs) {
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
// The selected year only - this used to total every year the account had ever been taxed in.
a.meta.tdsDeducted = a.meta.tdsApplicable
? a.taxLines.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0)
: 0;
}
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
// What was actually debited for this customer as deductee, wherever the savings account sits.
this.actualTds = this.accountInputs
.filter((a) => a.meta.tdsApplicable)
.reduce((s, a) => s + a.meta.tdsDeducted, 0);
if (!this.dateOfBirth) {
if (!this.dateOfBirth || this.configMissing) {
return;
}
@@ -378,21 +409,95 @@ export class Form121TabComponent implements OnInit, OnDestroy {
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
/** The declaration, but only when it was filed for the year being shown. */
private declarationForSelectedFy(): Declaration | null {
if (!this.declarationOnRecord || !this.fy) {
private loadPlatformYear(year: string): void {
this.tdsService
.getDepositInterest(Number(year.slice(0, 4)), Number(this.clientId))
.pipe(
catchError(() => of([])),
takeUntil(this.destroy$)
)
.subscribe((rows) => {
this.platformByFy.set(year, rows.find((r) => `${r.clientId}` === `${this.clientId}`) ?? null);
this.recompute();
});
}
/** The live declaration recorded for the year being shown, if any. */
get declarationRecord(): TdsDeclarationRecord | null {
if (!this.fy) {
return null;
}
const year = normaliseFy(this.fy);
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
return this.declarations.find((d) => d.fy === year && !d.revoked) ?? null;
}
get declarationOnRecord(): boolean {
return !!this.declarationRecord;
}
/** The declaration as the engine takes it — operative from the day it was accepted. */
private declarationForSelectedFy(): Declaration | null {
const d = this.declarationRecord;
if (!d) {
return null;
}
return {
kind: this.declarationKind,
// No acceptance date on record — treated as covering the whole year.
acceptedOn: `${year.slice(0, 4)}-04-01`,
fy: year
};
const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(this.dateOfBirth, d.fy);
return { kind, acceptedOn: d.acceptedOn, fy: d.fy, uin: d.uin || undefined };
}
openDeclarationForm(): void {
this.newDeclaration = { acceptedOn: new Date().toISOString().slice(0, 10), uin: '', estimatedIncome: null };
this.declarationError = '';
this.showDeclarationForm = true;
}
saveDeclaration(): void {
if (!this.newDeclaration.acceptedOn) {
this.declarationError = 'Enter the date the branch accepted the form.';
return;
}
this.savingDeclaration = true;
this.declarationError = '';
this.tdsService
.createDeclaration({
clientId: Number(this.clientId),
fy: normaliseFy(this.fy),
formType: declarationFormFor(this.fy, this.dateOfBirth),
acceptedOn: this.newDeclaration.acceptedOn,
uin: this.newDeclaration.uin || undefined,
estimatedIncome: this.newDeclaration.estimatedIncome
})
.subscribe({
next: (rows) => {
this.savingDeclaration = false;
this.showDeclarationForm = false;
this.declarations = rows || [];
this.recompute();
},
error: (err: any) => {
this.savingDeclaration = false;
this.declarationError =
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.';
}
});
}
revokeDeclaration(): void {
const d = this.declarationRecord;
if (!d) {
return;
}
const reason = prompt('Why is this declaration being withdrawn? From today, TDS will be deducted again.');
if (!reason) {
return;
}
this.tdsService.revokeDeclaration(d.id, reason).subscribe({
next: (rows) => {
this.declarations = rows || [];
this.recompute();
},
error: (err: any) => alert(err?.error?.defaultUserMessage || 'Could not revoke the declaration.')
});
}
// ───────────────────────── Template helpers ─────────────────────────
@@ -411,7 +516,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
/** The form the payee would file for the selected year. */
get formLabel(): string {
if (!this.fy) {
if (!this.fy || this.configMissing) {
return '—';
}
const form = declarationFormFor(this.fy, this.dateOfBirth);
@@ -470,11 +575,12 @@ export class Form121TabComponent implements OnInit, OnDestroy {
}
/**
* A declaration is on record but deposit accounts are still set to withhold —
* the branch has not switched them off.
* A declaration is on record but a deposit still carries a stock Fineract tax group. Those
* accounts withhold on their own rules and never consult the declaration record, so the form
* does not stop their deduction until the tax group is removed.
*/
get declarationNotAppliedToAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => !!a.taxGroup);
}
/**
@@ -591,16 +591,13 @@
<i class="fa fa-calendar section-header__icon"></i>
<h3 class="section-header__title">{{ 'labels.heading.Recurring Deposit Accounts' | translate }}</h3>
</div>
<div class="section-header__right">
<button mat-raised-button color="primary" (click)="toggleRecurringAccountsOverview()">
{{ viewAccountsLabel(showClosedRecurringAccounts) | translate }}
</button>
</div>
</div>
@if (!showClosedRecurringAccounts) {
<!-- Active and ended recurring deposits share one table; the Status column says how each one ended (matured or
closed early), so the client's RD history stays visible without switching views. -->
@if (savingAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring'">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
@@ -614,6 +611,15 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{ rdStatusLabel(element) }}</span>
@if (element.timeline?.closedOnDate) {
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="Last Active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
@@ -638,7 +644,7 @@
<i class="fa fa-check"></i>
</button>
}
@if (!element.status.submittedAndPendingApproval && !element.status.active) {
@if (element.status.approved) {
<button
class="account-action-button"
mat-raised-button
@@ -664,7 +670,7 @@
</ng-container>
<ng-container matColumnDef="no-data">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSavingsColumns.length">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="recurringColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">
@@ -674,67 +680,17 @@
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="openSavingsColumns"></tr>
<tr mat-header-row *matHeaderRowDef="recurringColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: openSavingsColumns"
*matRowDef="let row; columns: recurringColumns"
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
class="select-row"
></tr>
<tr
mat-footer-row
*matFooterRowDef="
(savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring')?.length === 0 ? ['no-data'] : []
"
></tr>
</table>
</div>
}
<!-- Closed Recurring Deposit Accounts -->
@if (showClosedRecurringAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Recurring Deposit Product' | translate }}</th>
<td mat-cell *matCellDef="let element">
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Closed Date">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="no-data">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Recurring Deposit Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="closedSavingsColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: closedSavingsColumns"
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
class="select-row"
></tr>
<tr
mat-footer-row
*matFooterRowDef="
(savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring')?.length === 0 ? ['no-data'] : []
(savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring')?.length === 0 ? ['no-data'] : []
"
></tr>
</table>
@@ -419,3 +419,24 @@ table[mat-table] {
font-size: 24px;
z-index: 1;
}
.rd-status {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
background: #dcfce7;
color: #166534;
&--ended {
background: #e2e8f0;
color: #475569;
}
}
.rd-status__date {
margin-top: 2px;
font-size: 11px;
color: #64748b;
}
@@ -187,6 +187,15 @@ export class GeneralTabComponent implements OnDestroy {
'Saving Account',
'Closed Date'
];
/** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */
recurringColumns: string[] = [
'Account No',
'Saving Account',
'Status',
'Last Active',
'Balance',
'Actions'
];
/** Open Shares Accounts Columns */
openSharesColumns: string[] = [
'Account No',
@@ -475,6 +484,30 @@ export class GeneralTabComponent implements OnDestroy {
);
}
/** Where a recurring deposit stands - for one that has ended, how it ended. */
rdStatusLabel(account: any): string {
switch (account?.status?.code) {
case 'savingsAccountStatusType.active':
return 'Active';
case 'savingsAccountStatusType.matured':
return 'Matured (not closed)';
case 'savingsAccountStatusType.submitted.and.pending.approval':
return 'Pending Approval';
case 'savingsAccountStatusType.approved':
return 'Approved';
case 'savingsAccountStatusType.closed':
return 'Matured & Closed';
case 'savingsAccountStatusType.pre.mature.closure':
return 'Premature Closed';
case 'savingsAccountStatusType.rejected':
return 'Rejected';
case 'savingsAccountStatusType.withdrawn.by.applicant':
return 'Withdrawn by Client';
default:
return account?.status?.value ?? '';
}
}
viewAccountsLabel(closed: boolean): string {
if (closed) {
return 'labels.buttons.View Active Accounts';
@@ -1247,9 +1247,7 @@
</div>
@if (!docScansLoading && !docScans.front && !docScans.back) {
<div class="kyc-doc-scans__empty">
No scan is on file for this document. Upload one below.
</div>
<div class="kyc-doc-scans__empty">No scan is on file for this document. Upload one below.</div>
}
<div class="kyc-doc-scans__sides">
@@ -182,7 +182,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null };
/** The two sides, in display order, so the template loops rather than repeating itself. */
readonly docScanSlots: ('front' | 'back')[] = ['front', 'back'];
readonly docScanSlots: ('front' | 'back')[] = [
'front',
'back'
];
docScanOf(slot: 'front' | 'back') {
return this.docScans[slot];
@@ -474,7 +477,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
adverseMediaMatch: !!v.adverseMediaMatch,
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
// customer who declared no income at all.
declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
declaredAnnualIncome:
v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
declaredAnnualTurnover:
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
};
@@ -519,7 +523,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(),
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
if (!this.kycId) {
@@ -766,7 +770,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
riskCategory: this.kycForm.value.riskCategory || 1,
...declarationFields,
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
this.clientsService
@@ -927,7 +931,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
riskCategory: this.kycForm.value.riskCategory || 1,
...receiptFields,
...this.buildRiskProfileFields(),
...this.buildExceptionFields()
...this.buildExceptionFields()
};
this.clientsService
@@ -1455,7 +1459,10 @@ I further confirm that:
this.docScansLoading = false;
// This runs again after every replace, so the previews being thrown away are revoked
// rather than left holding their blobs.
for (const old of [this.docScans.front, this.docScans.back]) {
for (const old of [
this.docScans.front,
this.docScans.back
]) {
if (old?.previewUrl) {
URL.revokeObjectURL(old.previewUrl);
}
@@ -1497,7 +1504,10 @@ I further confirm that:
}
this.docScans = sides;
for (const key of ['front', 'back'] as const) {
for (const key of [
'front',
'back'
] as const) {
if (sides[key]) {
this.loadScanPreview(sides[key]!);
}
@@ -1558,7 +1568,9 @@ I further confirm that:
error: (err: any) => {
this.docScanUploading = null;
this.docScanError =
err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`;
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
`Could not replace the ${label.toLowerCase()} side.`;
}
});
}
@@ -1741,7 +1753,10 @@ I further confirm that:
this.destroy$.next();
this.destroy$.complete();
// Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab.
for (const side of [this.docScans.front, this.docScans.back]) {
for (const side of [
this.docScans.front,
this.docScans.back
]) {
if (side?.previewUrl) {
URL.revokeObjectURL(side.previewUrl);
}
+33 -6
View File
@@ -569,8 +569,19 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/documents`);
}
downloadClientDocument(parentEntityId: string, documentId: string) {
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, { responseType: 'blob' });
/**
* Fetches a client document's stored file.
*
* `silent` opts the request out of ErrorHandlerInterceptor's alert. Pass it when the caller
* renders its own failure state - notably thumbnail fetches, where a `m_document` row whose file
* is no longer in the content store returns 500 and would otherwise pop a modal the user can do
* nothing about. The interceptor still rethrows, so the caller's error callback continues to run.
*/
downloadClientDocument(parentEntityId: string, documentId: string, silent = false) {
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, {
responseType: 'blob',
...(silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {})
});
}
uploadClientDocument(clientId: string, documentData: any) {
@@ -992,12 +1003,28 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data);
}
getClientCkyc(clientId: string): Observable<any> {
return this.http.get(`/clients/${clientId}/ckyc`);
/**
* Uploads a single CKYC artefact against the client, using the platform's generic document
* endpoint (DocumentApiResource, POST /v1/{entityType}/{entityId}/documents). There is no
* CKYC-specific document API on the backend - the earlier `/clients/{id}/ckyc/documents` path
* was never implemented and returned 404.
*
* The multipart body must carry `file`, `name` and `description`; the caller builds those so the
* stored document stays identifiable by CKYC artefact type. See ckyc-tab.component.ts.
*/
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
return this.http.post(`/clients/${clientId}/documents`, formData);
}
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
return this.http.post(`/clients/${clientId}/ckyc/documents`, formData);
/**
* Replaces the content of an existing client document in place (DocumentApiResource PUT
* /v1/{entityType}/{entityId}/documents/{documentId}), keeping the same document id.
*
* Used by the CKYC screen's "Upload new version" so a re-upload updates the existing artefact
* rather than adding another row. Takes the same multipart body as the create call.
*/
replaceClientDocument(clientId: string, documentId: string, formData: FormData): Observable<any> {
return this.http.put(`/clients/${clientId}/documents/${documentId}`, formData);
}
/**
@@ -377,6 +377,8 @@
</button>
}
@if (selectedTabIndex < totalTabs - 1) {
<!-- Tooltip sits on a wrapper: a disabled button fires no mouse events, so it can't host it. -->
<span [matTooltip]="currentStepBlockerMessage" [matTooltipDisabled]="!currentStepBlockerMessage">
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid">
@if (savingClient) {
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
@@ -390,6 +392,7 @@
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
}
</button>
</span>
}
@if (selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" [routerLink]="['../']">
@@ -393,6 +393,12 @@ export class EditClientComponent {
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
}
if (this.selectedTabIndex === 0 && this.clientGeneralForm && !this.clientGeneralForm.valid) {
const labels = this.invalidGeneralFieldLabels();
if (labels.length) {
return `Please fill/correct: ${labels.join(', ')}.`;
}
}
if (this.selectedTabIndex === 1) {
const labels = this.clientKycStep?.invalidFieldLabels || [];
if (labels.length) {
@@ -408,6 +414,42 @@ export class EditClientComponent {
return 'Please fill all mandatory fields on this tab to continue.';
}
/**
* Readable names of the General-tab controls that are invalid. Older clients can be missing
* fields that are now mandatory (e.g. mobile/email), which leaves Save disabled with nothing
* on screen saying why — this feeds the button tooltip.
*/
private invalidGeneralFieldLabels(): string[] {
const labels: Record<string, string> = {
officeId: 'Office',
legalFormId: 'Legal Form',
genderId: 'Gender',
mobileNo: 'Mobile No',
alternateMobileNo: 'Alternate Mobile No',
emailAddress: 'Email',
dateOfBirth: 'Date of Birth',
submittedOnDate: 'Submitted On',
activationDate: 'Activation Date',
savingsProductId: 'Savings Product',
firstname: 'First Name',
middlename: 'Middle Name',
lastname: 'Last Name',
fullname: 'Name',
monthlyIncome: 'Monthly Income',
annualIncome: 'Annual Income',
monthlyTurnover: 'Monthly Turnover',
annualTurnover: 'Yearly Turnover',
agricultureIncome: 'Agriculture Income',
nonAgricultureIncome: 'Non-Agriculture Income',
guardianDetails: 'Guardian',
clientNonPersonDetails: 'Entity Details'
};
const controls = this.clientGeneralForm?.controls || {};
return Object.keys(controls)
.filter((name) => controls[name].invalid)
.map((name) => labels[name] || name);
}
/** Whether the current tab has unsaved edits */
isCurrentTabEdited(): boolean {
return this.tabEditMode[this.selectedTabIndex] === true;
@@ -109,13 +109,7 @@
</button>
}
</div>
<input
#sideInput
type="file"
hidden
accept="image/*,application/pdf"
(change)="onFilePicked($event, slot)"
/>
<input #sideInput type="file" hidden accept="image/*,application/pdf" (change)="onFilePicked($event, slot)" />
</div>
}
</div>
+36 -1
View File
@@ -25,6 +25,41 @@ import { environment } from '../../../environments/environment';
export class CacheInterceptor implements HttpInterceptor {
private forceUpdate = false;
/**
* Endpoints that compute a live figure from the current ledger/configuration and must never be
* served from cache. Caching them meant a report kept showing a stale response after its
* underlying GL mapping changed — the request was answered from cachedData and the backend was
* never hit again. Matched as a substring of the request URL.
*/
private static readonly NEVER_CACHE = [
'/rbireservereturn',
'/rbireserveconfig',
'/tdsconfig',
'/tds/deposit-interest',
// Declarations and challans are entered on the very screens that read them back.
'/tds/declarations',
'/tds/challans',
'/tds/deducted',
'/savingsinterestpostingreports',
// Cash in vaults and drawers changes with every transaction; a cached teller list or position
// kept showing a branch's old tellers and figures at day close.
'/tellers',
'/cashreports',
'/cashreconciliation',
'/cashsettlement',
'/cashtransfer',
'/branchday',
// A deposit's lien moves with every repayment of the loan it secures, and decides whether the
// deposit may be closed at all - a stale copy would show a released lien as still blocking.
'/liens',
// The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken.
'/recurringdepositaccounts/dashboard'
];
private isCacheable(url: string): boolean {
return !CacheInterceptor.NEVER_CACHE.some((fragment) => url.includes(fragment));
}
// eslint-disable-next-line @angular-eslint/prefer-inject
constructor(private httpCacheService: HttpCacheService) {}
@@ -42,7 +77,7 @@ export class CacheInterceptor implements HttpInterceptor {
}
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (request.method !== 'GET') {
if (request.method !== 'GET' || !this.isCacheable(request.urlWithParams)) {
return next.handle(request);
}
+27 -5
View File
@@ -67,12 +67,18 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
// endpoint isn't available on a given backend deployment, the client-general-step
// component already falls back to riskRules = [] silently; no toast needed here either.
const isKycRiskRulesRequest = request.url.includes('/kyc/risk-rules');
// A document's own file can be missing from the content store (a database copied between
// environments without its file store is the usual cause) even though its m_document row
// survives — the backend reports that as a 404, same as a client with no photo, and every
// caller that fetches a signature/attachment image already recovers with its own catchError.
const isDocumentAttachmentRequest = request.url.match(/\/documents\/\d+\/attachment$/);
const isSilentRequest =
isClientImageRequest ||
isClientKycRequest ||
isDirectorRequest ||
isClientPendingEditRequest ||
isKycRiskRulesRequest;
isKycRiskRulesRequest ||
isDocumentAttachmentRequest;
// Explicit per-request opt-out (e.g. per-row enrichment fetches on a list page whose
// component already recovers via its own catchError) — silenced for every status, since
@@ -93,8 +99,8 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
throw response;
}
// Resolved once and emitted in one place below, so every failure here is consistently tagged
// as an error and therefore surfaces as a centred modal rather than a corner toast.
// Resolved once and emitted in one place below — see the severity decision there for which of
// these surfaces as a centred modal versus a corner toast.
let alert: { type: string; message: string } | null = null;
if (status === 401 || (environment.oauth.enabled && status === 400)) {
alert = { type: 'Authentication Error', message: 'Invalid User Details. Please try again!' };
@@ -119,7 +125,13 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
message: errorMessage || 'This action is not allowed in the current state.'
};
} else if (status === 500) {
alert = { type: 'Internal Server Error', message: 'Internal Server Error. Please try again later.' };
// errorMessage carries the backend's actual explanation (defaultUserMessage/developerMessage)
// when one is present; the generic sentence is only a last resort for a response with no body
// at all (a proxy timeout, a stack trace the server never wrapped into JSON).
alert = {
type: 'Internal Server Error',
message: errorMessage || 'Internal Server Error. Please try again later.'
};
} else if (status === 501) {
alert = {
type: this.translate.instant('error.resource.notImplemented.type'),
@@ -133,7 +145,17 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
}
if (alert) {
this.alertService.alert({ ...alert, severity: 'error' });
// A GET is the page reading data it did not ask the user for, in the background — a photo,
// an attachment, a notes list, a standing-instructions panel. Every one of those already has
// its own catchError fallback at the call site (see transaction-entry.component.ts and
// similar account-overview loaders), so the page keeps working regardless. Blocking the whole
// screen on a dialog the user has to click "OK" on, for data that was never more than a
// secondary panel, is the "irritate na ho" complaint this guards against — it goes to the
// corner toast instead, same place a non-error alert already goes. A write (POST/PUT/PATCH/
// DELETE) is different: that is the one action the user was waiting on, so its failure still
// needs the centred, must-acknowledge dialog.
const isBackgroundRead = request.method === 'GET' && (status === 500 || status === 501 || status === 503);
this.alertService.alert({ ...alert, severity: isBackgroundRead ? undefined : 'error' });
}
throw response;
+74 -21
View File
@@ -65,6 +65,14 @@
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
</nav>
}
<!-- FD Dashboard fullscreen — ESC hint bar -->
@else if (currentUrl.includes('/fd-dashboard')) {
<nav class="cashier-nav cashier-nav--election">
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()">
<kbd>ESC</kbd> Exit Fullscreen (Show Header &amp; Footer)
</span>
</nav>
}
<!-- Clerk Dashboard fullscreen nav — only visible at /clerk/* -->
@else if (currentUrl.includes('/clerk/')) {
<nav class="cashier-nav cashier-nav--clerk">
@@ -655,6 +663,12 @@
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<button mat-menu-item routerLink="/reports/interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/interest-posting-summary" tabindex="0">
<i class="fa fa-table menu-item-icon"></i> Interest Posting Summary (Branch-wise)
</button>
<!--
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
so it never shows up under the report categories above and had no nav entry at all.
@@ -1203,8 +1217,11 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> FD Maturity Report
<button mat-menu-item tabindex="0" [routerLink]="['/fd-dashboard']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> FD Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/rd-dashboard']">
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> RD Dashboard
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
@@ -1300,72 +1317,79 @@
<!-- Share Operations -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Share Operations</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/nominal-shares']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Nominal Share Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-wise']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Share Wise Report
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/guarantor-detail']">
<fa-icon icon="user-shield" class="menu-item-icon"></fa-icon> Guarantor Detail
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/member-interest']">
<fa-icon icon="calculator" class="menu-item-icon"></fa-icon> Total Int Rec
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/enquiry']">
<fa-icon icon="search" class="menu-item-icon"></fa-icon> Share Enquiry
</button>
</div>
<!-- Share Documents -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Share Documents</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/payment-certificate']">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Share Payment Certificate
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-detail']">
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> Share Detail
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/dividend-statement']">
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Dividend Statement
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/transfers']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Share Transfer
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/folio-statement']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Folio Statement
</button>
</div>
<!-- Share Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Share Reports</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/final-balance']">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> Final Balance
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/account-statement']">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Account Statement
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-issued']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']">
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/share-status']">
<fa-icon icon="info-circle" class="menu-item-icon"></fa-icon> Share Status
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/product-summary']">
<fa-icon icon="layer-group" class="menu-item-icon"></fa-icon> Product Summary
</button>
</div>
<!-- Share Admin -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Share Admin</div>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/distinctive-numbers']">
<fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Devede No Detail
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise</button>
<button mat-menu-item tabindex="0"><fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Label</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/area-wise']">
<fa-icon icon="map" class="menu-item-icon"></fa-icon> Area Wise
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/labels']">
<fa-icon icon="tag" class="menu-item-icon"></fa-icon> Share Label
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/shares/reports/letters']">
<fa-icon icon="envelope" class="menu-item-icon"></fa-icon> Share Letter
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/letter-mgmt/share-member']">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Share Member Details
</button>
</div>
@@ -2056,6 +2080,35 @@
<fa-icon icon="users" class="gl-icon" style="color: #4f46e5"></fa-icon> Members &amp; Share Capital
</a>
</div>
<!-- Reserve Returns — CRR and SLR, computed live from the ledger. Grouped apart from the
OSS returns because they are filed on their own cycles: Form 1 twice a month, Form 9
at month end. -->
<div class="gl-group">
<div class="gl-group-title">Reserve Returns</div>
<!-- The statutory form is a tab on Form 1 rather than an entry of its own: two menu
items for one return had a filer guessing which one they were looking at. -->
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Form 1 CRR Cash Reserve Ratio')"
routerLink="/rbi/form1"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form 1 — CRR (fortnightly)
</a>
<!-- Form 9 (SLR) has no entry of its own. Section 24 is settled on the statutory Form 1,
at Part C, on the same liabilities Part B charges the cash reserve on — so the SLR
position is read there rather than on a second screen that computed it from a
differently drawn base and disagreed. -->
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Form IX R408 Return on Assets and Liabilities balance sheet')"
routerLink="/rbi/form9-return"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form IX — Assets &amp;
Liabilities (R408)
</a>
</div>
<!-- CIMS / XBRL Returns -->
<div class="gl-group">
<div class="gl-group-title">CIMS / XBRL Returns</div>
+9 -7
View File
@@ -167,7 +167,9 @@ export class ShellComponent implements OnInit, OnDestroy {
url.includes('/clerk/withdrawal-request') ||
url.includes('/clerk/my-disbursement-requests');
const isElectionRoute = url.includes('/election');
this.hideShellLayout = isCashierOnly || isClerkRoute || isElectionRoute || this.isWithdrawalReviewUrl;
const isFdDashboardRoute = url.includes('/fd-dashboard');
this.hideShellLayout =
isCashierOnly || isClerkRoute || isElectionRoute || isFdDashboardRoute || this.isWithdrawalReviewUrl;
this.cdr.detectChanges();
};
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout());
@@ -180,10 +182,7 @@ export class ShellComponent implements OnInit, OnDestroy {
});
}
/**
* Toggles the current collapsed state of sidenav according to the emitted event.
* @param {boolean} event denotes state of sidenav
*/
/** Toggles the current collapsed state of sidenav according to the emitted event. */
toggleCollapse($event: boolean) {
this.sidenavCollapsed = $event;
this.cdr.detectChanges();
@@ -204,12 +203,15 @@ export class ShellComponent implements OnInit, OnDestroy {
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
}
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
/** ESC key — exit fullscreen at Clerk Dashboard / Election / FD Dashboard / review screen, restoring full shell header. */
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (
this.hideShellLayout &&
(this.router.url.includes('/clerk/') || this.router.url.includes('/election') || this.isWithdrawalReviewUrl)
(this.router.url.includes('/clerk/') ||
this.router.url.includes('/election') ||
this.router.url.includes('/fd-dashboard') ||
this.isWithdrawalReviewUrl)
) {
this.hideShellLayout = false;
this.cdr.detectChanges();
@@ -20,7 +20,7 @@
<div class="t-live"></div>
&nbsp;LIVE
</div>
<div class="t-chip">Branch:&nbsp;<strong>HO</strong></div>
<div class="t-chip">Branch:&nbsp;<strong>{{ officeLabel }}</strong></div>
<div class="t-chip">
Date:&nbsp;<strong>{{ today | date: 'dd MMM yyyy' }}</strong>
</div>
@@ -49,6 +49,7 @@ import { MatIcon } from '@angular/material/icon';
import { NotificationsTrayComponent as NotificationsTrayComponent_1 } from '../../../shared/notifications-tray/notifications-tray.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DocumentationLinksService } from 'app/shared/services/documentation-links.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Toolbar component.
@@ -92,6 +93,14 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
@ViewChild('templateAppMenu') templateAppMenu: TemplateRef<any>;
@ViewChild('notificationsTray') notificationsTray: NotificationsTrayComponent;
/** The signed-in user's office — this chip read "HO" for every login, branch staff included. */
get officeLabel(): string {
if (this.authenticationService.isHeadOfficeUser()) {
return 'HO';
}
return this.authenticationService.getCredentials()?.officeName || '—';
}
/** Subscription to breakpoint observer for handset. */
isHandset$: Observable<boolean> = this.breakpointObserver
.observe(Breakpoints.Handset)
@@ -100,8 +109,10 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
sidenavCollapsed = true;
/** Current date for top bar display */
today = new Date();
/** The bank's "today" for the top bar — the business date when enabled, else the server date. */
get today(): Date {
return businessToday();
}
/** Last login time display */
lastLoginTime = this.formatLastLogin();
+30
View File
@@ -0,0 +1,30 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* The bank's "today": the business date while `enable-business-date` is on, the server's own date
* while it is off. The server decides which (GET /branchday/today); the web app stores the answer
* in `mifosXServerDate` at login and whenever the business date changes. Before that has happened
* the browser clock is the only date there is, so it is the fallback.
*/
export function businessToday(): Date {
const stored = localStorage.getItem('mifosXServerDate');
const match = stored ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(stored) : null;
if (!match) {
return new Date();
}
// Built from parts so it is local midnight — new Date('yyyy-MM-dd') would be UTC midnight and
// land on the previous day in any timezone behind UTC.
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
}
/** {@link businessToday} as yyyy-MM-dd. */
export function businessTodayIso(): string {
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -8,7 +8,10 @@
<div class="cur-view">
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
<mifosx-dormancy-alert-panel [accountData]="currentAccountData"></mifosx-dormancy-alert-panel>
<!-- Shown only when the account's product has Dormancy Alert = Yes -->
@if (currentAccountData?.dormancyAlert) {
<mifosx-dormancy-alert-panel [accountData]="currentAccountData"></mifosx-dormancy-alert-panel>
}
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
<header class="cur-hero">
@@ -59,16 +59,7 @@
<span class="nx-mono">[{{ currentAccountData.currency.code }}]</span>
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Nominal Interest Rate' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
@if (currentAccountData.nominalAnnualInterestRate) {
{{ currentAccountData.nominalAnnualInterestRate | formatNumber }} %
} @else {
0 %
}
</span>
</div>
<!-- A current account earns no interest, so no interest rate is shown here; OD interest is under Overdraft. -->
} @else {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Field Officer' | translate }}</span>
@@ -102,17 +93,17 @@
</span>
</div>
}
@if (currentAccountData.summary.totalInterestEarned >= 0) {
@if (currentAccountData.summary.totalWithdrawals) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Total Interest Earned' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
<span class="nx-row__label">{{ 'labels.inputs.Total Withdrawls' | translate }}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--negative">
{{
currentAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2'
currentAccountData.summary.totalWithdrawals | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (!currentAccountData.summary.totalDeposits && !(currentAccountData.summary.totalInterestEarned >= 0)) {
@if (!currentAccountData.summary.totalDeposits && !currentAccountData.summary.totalWithdrawals) {
<div class="nx-empty">
<fa-icon icon="inbox" class="nx-empty__ico"></fa-icon>
<span>No activity yet.</span>
@@ -132,16 +123,7 @@
<h3 class="nx-card__title">{{ 'labels.heading.Account Summary' | translate }}</h3>
</header>
<div class="nx-card__body">
@if (currentAccountData.summary.totalWithdrawals) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Total Withdrawls' | translate }}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--negative">
{{
currentAccountData.summary.totalWithdrawals | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
<!-- A current account earns no interest: no interest earned/posted rows. OD interest has its own card. -->
@if (currentAccountData.summary.totalWithdrawalFees) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withdrawals Fees' | translate }}</span>
@@ -160,89 +142,6 @@
</span>
</div>
}
@if (currentAccountData.summary.totalInterestEarned >= 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Earned' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.summary.totalInterestPosted) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Posted' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.totalInterestPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.summary.interestNotPosted >= 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Earned Not Posted' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.summary.totalOverdraftInterestDerived) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest On Overdraft' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.totalOverdraftInterestDerived
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.summary.interestNotPosted < 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Overdraft Interest Not Posted' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.nominalAnnualInterestRate) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Nominal Interest Rate' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ currentAccountData.nominalAnnualInterestRate | formatNumber }} %
</span>
</div>
}
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Compounding Period' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.interestCompoundingPeriodType.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Posting Period' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.interestPostingPeriodType.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Calculated Using' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.interestCalculationType.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Days in Year' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.interestCalculationDaysInYearType.value | translateKey: 'catalogs' }}
</span>
</div>
@if (currentAccountData.withdrawalFee) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withdrawal Fee' | translate }}</span>
@@ -289,37 +188,6 @@
</span>
</div>
}
@if (currentAccountData.allowOverdraft) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Overdraft Limit' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ currentAccountData.overdraftLimit | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (currentAccountData.allowOverdraft) {
<div class="nx-row">
<span class="nx-row__label">
{{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}
</span>
<span class="nx-row__value">
{{
currentAccountData.minOverdraftForInterestCalculation
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.minBalanceForInterestCalculation) {
<div class="nx-row">
<span class="nx-row__label">
{{ 'labels.inputs.Min Balance Required for Interest Calculation' | translate }}
</span>
<span class="nx-row__value">
{{ currentAccountData.minBalanceForInterestCalculation | formatNumber }}
</span>
</div>
}
@if (currentAccountData.minRequiredBalance) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Minimum Required Balance' | translate }}</span>
@@ -332,14 +200,6 @@
<span class="nx-row__value">{{ currentAccountData.enforceMinRequiredBalance | yesNo }}</span>
</div>
}
@if (currentAccountData.summary.lastInterestCalculationDate) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Recalculation Date' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.summary.lastInterestCalculationDate | dateFormat }}
</span>
</div>
}
@if (currentAccountData.onHoldFunds) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.On Hold Funds' | translate }}</span>
@@ -350,14 +210,72 @@
</span>
</div>
}
@if (currentAccountData.withHoldTax) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withhold Tax Group' | translate }}</span>
<span class="nx-row__value">{{ currentAccountData.taxGroup.name }}</span>
</div>
}
</div>
</section>
<!-- Overdraft: the only interest on a current account is what the customer pays on an OD. -->
@if (currentAccountData.allowOverdraft) {
<section class="nx-card">
<header class="nx-card__head">
<fa-icon icon="hand-holding-usd" class="nx-card__ico"></fa-icon>
<h3 class="nx-card__title">Overdraft (OD)</h3>
</header>
<div class="nx-card__body">
<div class="nx-row">
<span class="nx-row__label">Maximum OD Limit (per product)</span>
<span class="nx-row__value nx-row__value--amount">
{{ currentAccountData.overdraftLimit | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
@if (currentAccountData.nominalAnnualInterestRateOverdraft) {
<div class="nx-row">
<span class="nx-row__label">OD Interest Rate</span>
<span class="nx-row__value">
{{ currentAccountData.nominalAnnualInterestRateOverdraft | formatNumber }} % p.a.
</span>
</div>
}
@if (currentAccountData.minOverdraftForInterestCalculation) {
<div class="nx-row">
<span class="nx-row__label">Minimum OD for Interest</span>
<span class="nx-row__value">
{{
currentAccountData.minOverdraftForInterestCalculation
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
<div class="nx-row">
<span class="nx-row__label">OD Interest Posting</span>
<span class="nx-row__value">
{{ currentAccountData.interestPostingPeriodType?.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Days in Year' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.interestCalculationDaysInYearType?.value | translateKey: 'catalogs' }}
</span>
</div>
@if (currentAccountData.validForPeriod) {
<div class="nx-row">
<span class="nx-row__label">Sanction Validity</span>
<span class="nx-row__value">{{ currentAccountData.validForPeriod }}</span>
</div>
}
<div class="nx-row">
<span class="nx-row__label">OD Interest Charged</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.totalOverdraftInterestDerived || 0
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
</div>
</section>
}
} @else {
<section class="nx-card">
<header class="nx-card__head">
@@ -54,8 +54,8 @@
<mat-icon>account_balance</mat-icon>
</div>
<div class="bank-name-block">
<div class="bank-title">LincPay Co-operative Bank Ltd.</div>
<div class="bank-subtitle">Current Account Statement</div>
<div class="bank-title">{{ bankName }}</div>
<div class="bank-subtitle">{{ bankSubtitle }}</div>
</div>
</div>
<div class="statement-label">
@@ -64,6 +64,25 @@
</div>
</div>
<!-- Branch line — IFSC/MICR are how a UCB customer identifies which branch issued this, and are
needed for NEFT/RTGS/cheque printing, so a real passbook always carries them. -->
@if (branchName || ifscCode || micrCode) {
<div class="branch-line">
@if (branchName) {
<span>{{ branchName }} Branch</span>
}
@if (branchAddress) {
<span>{{ branchAddress }}</span>
}
@if (ifscCode) {
<span>IFSC: {{ ifscCode }}</span>
}
@if (micrCode) {
<span>MICR: {{ micrCode }}</span>
}
</div>
}
<!-- Account Details -->
<div class="account-details-band">
<div class="account-details-grid">
@@ -127,6 +127,18 @@ $header-text: #fff;
}
}
// ─── Branch Identification Line ──────────────────────────────────────────────
.branch-line {
background: #e8edf5;
border-bottom: 1px solid $border-color;
padding: 6px 24px;
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
font-size: 11px;
color: #4a5a7a;
}
// ─── Account Details Band ────────────────────────────────────────────────────
.account-details-band {
background: #f0f3f9;
@@ -11,11 +11,12 @@ import { Component, OnInit, OnDestroy, inject, ElementRef, ViewChild } from '@an
import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder } from '@angular/forms';
import { CurrencyPipe } from '@angular/common';
import { Subject, takeUntil } from 'rxjs';
import { Subject, takeUntil, catchError, forkJoin, of } from 'rxjs';
/** Custom Services */
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { OrganizationService } from '../../../organization/organization.service';
import { Currency } from 'app/shared/models/general.model';
import {
CurrentAccountTransaction,
@@ -44,6 +45,7 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private organizationService = inject(OrganizationService);
private destroy$ = new Subject<void>();
@@ -60,6 +62,17 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
availableBalance: number = 0;
activatedOnDate: any = null;
/** Bank/branch letterhead — real values from Institution Profile + Branch Profile (Organization
* module) rather than a hardcoded name, so a printed passbook actually identifies which bank and
* branch issued it, the way a real UCB's core banking system does. Falls back to a generic label
* if the profile was never filled in, rather than showing a fabricated bank name. */
bankName = 'Bank';
bankSubtitle = 'Current Account Statement';
branchName = '';
branchAddress = '';
ifscCode = '';
micrCode = '';
/** Transactions */
allTransactions: CurrentAccountTransaction[] = [];
filteredTransactions: CurrentAccountTransaction[] = [];
@@ -92,6 +105,33 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
fromDate: [''],
toDate: ['']
});
this.loadLetterhead();
}
/** Institution Profile is the bank's own single record; Branch Profile is per-office. There is no
* office id on the current account data reaching this tab, and this deployment runs a single
* branch, so the first branch profile on record is the correct one here — a genuinely
* multi-branch deployment would need the account's own office id threaded through instead. */
private loadLetterhead(): void {
forkJoin({
institution: this.organizationService.getInstitutionProfile().pipe(catchError(() => of(null))),
branches: this.organizationService.getBranchProfiles().pipe(catchError(() => of([])))
}).subscribe(({ institution, branches }: any) => {
if (institution) {
this.bankName = institution.passbookHeaderName || institution.bankName || institution.legalName || 'Bank';
this.bankSubtitle = institution.licenseNumber
? `Lic. No. ${institution.licenseNumber}`
: 'Current Account Statement';
}
const branch = Array.isArray(branches) ? branches[0] : null;
if (branch) {
this.branchName = branch.officeName || '';
this.branchAddress = branch.address || '';
this.ifscCode = branch.ifscCode || '';
this.micrCode = branch.micrCode || '';
}
});
}
private computeRunningBalances(transactions: CurrentAccountTransaction[]): CurrentAccountTransaction[] {
@@ -279,6 +319,18 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
margin-top: 3px;
}
/* Branch identification line */
.branch-line {
background: #e8edf5;
border-bottom: 1px solid #d0d7e3;
padding: 5px 20px;
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
font-size: 9.5px;
color: #4a5a7a;
}
/* Account details band */
.account-details-band {
background: #f0f3f9;
@@ -334,14 +334,19 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
if (this.principalAmount && this.principalAmount > 0) {
// Convert tenure to months for backend API
let tenureInMonths = 0;
// Years and Months convert exactly; a Days tenure is sent as days and never converted.
let tenureInMonths: number | undefined;
let tenureInDays: number | undefined;
if (this.tenurePeriodUnit === 'Years') {
tenureInMonths = this.tenureValue * 12;
} else if (this.tenurePeriodUnit === 'Months') {
tenureInMonths = this.tenureValue;
} else {
tenureInMonths = Math.floor(enteredDays / 30);
// Sent as days, whatever the count. Converting to whole months silently dropped the
// remainder — a 200-day deposit quoted as six months lost 20 days of interest, and the
// screen still said "200 Days" above the figure. The backend takes days directly and
// works the calendar out itself.
tenureInDays = enteredDays;
}
// Map compounding period type id → months (0=Daily, 1=Daily, 4=Monthly, 5=Quarterly, 6=SemiAnnual, 7=Annual)
@@ -395,6 +400,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
principalAmount: this.principalAmount,
annualInterestRate: rate,
tenureInMonths,
tenureInDays,
interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend
interestPostingPeriodInMonths: postMonths,
submittedOnDate: submittedOnDateStr,
@@ -417,10 +423,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100;
} else {
const n = 12 / compMonths;
const totalCompoundings = Math.floor(tenureInMonths / compMonths);
const effectiveMonths = tenureInMonths ?? enteredDays / 30;
const rPerCompounding = rate / (n * 100);
result.maturityAmount =
Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 100) / 100;
// Whole compounding periods, then simple interest on the days left over. Flooring the
// periods and stopping there dropped the remainder entirely: a 200-day deposit
// compounding monthly quoted only six months of interest and lost the other twenty
// days, which is the depositor's money.
const wholePeriods = Math.floor(effectiveMonths / compMonths);
const compounded = this.principalAmount * Math.pow(1 + rPerCompounding, wholePeriods);
const leftoverDays = enteredDays - wholePeriods * compMonths * 30;
const leftoverInterest =
leftoverDays > 0 ? (compounded * rate * leftoverDays) / (100 * daysInYearVal) : 0;
result.maturityAmount = Math.round((compounded + leftoverInterest) * 100) / 100;
}
result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100;
this.matchResult = { ...result };
@@ -7,17 +7,29 @@
-->
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column">
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
<h4 class="mat-h4 flex-98">{{ 'labels.heading.Maturity Instructions' | translate }}</h4>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
<mat-select formControlName="maturityInstructionId">
<mat-option [value]="''">-- Not selected (decide later via FD Dashboard) --</mat-option>
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
<mat-option [value]="maturityInstruction.id">
{{ maturityInstruction.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
@if (closureSettingsForm.value.maturityInstructionId) {
<button
mat-icon-button
matSuffix
type="button"
title="Clear -- decide later via FD Dashboard"
(click)="$event.stopPropagation(); closureSettingsForm.get('maturityInstructionId')?.setValue('')"
>
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
@if (closureSettingsForm.contains('transferToSavingsId')) {
@@ -28,8 +40,8 @@
<mat-icon>savings</mat-icon>
</div>
<div>
<h3 class="dal-card__title">{{ 'labels.inputs.Transfer to savings account' | translate }}</h3>
<span class="dal-card__sub">Maturity proceeds will be credited to this account</span>
<h3 class="dal-card__title">{{ transferAccountCardTitle }}</h3>
<span class="dal-card__sub">{{ transferAccountCardSubtitle }}</span>
</div>
</div>
</div>
@@ -69,7 +81,7 @@
</div>
} @else {
<mat-form-field appearance="outline" class="flex-98">
<mat-label>{{ 'labels.inputs.Transfer to savings account' | translate }}</mat-label>
<mat-label>{{ transferAccountCardTitle }}</mat-label>
<mat-select formControlName="transferToSavingsId" required>
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
@@ -77,13 +89,15 @@
</mat-option>
}
</mat-select>
<mat-error>Transfer to savings account is required</mat-error>
<mat-error>{{ transferAccountCardTitle }} is required</mat-error>
</mat-form-field>
}
</div>
</div>
}
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@@ -11,6 +11,7 @@ import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/cor
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatIcon
MatIcon,
MatIconButton
]
})
export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges {
@@ -53,9 +55,12 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges
preClosurePenalInterestOnTypeId: ['']
});
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
// 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both
// Reinvest options (300/400) open a brand-new FD instead, so they ignore this field too.
if (value === 200) {
// 200 = Transfer to Savings -- the whole matured balance goes here. 400 = Reinvest Principal
// Only -- the principal rolls into a new FD, but the leftover interest still needs the same
// kind of target so it isn't left stranded on the closed account (a real gap the account used
// to silently hit before this field also asked for it here). Reinvest Principal + Interest
// (300) rolls the whole balance into the same account, so it never needs a target at all.
if (value === 200 || value === 400) {
this.closureSettingsForm.addControl(
'transferToSavingsId',
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
@@ -91,15 +96,48 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges
});
}
}
// Editing an existing account: its own already-saved Maturity Instructions choice lives on
// `fixedDepositsAccountTemplate` (the real account+template GET, FixedDepositAccountData's
// `onAccountClosure`) -- NOT on `fixedDepositsAccountProductTemplate`, which is only the blank
// per-product default template refetched from the Product dropdown's own productId (via
// getFixedDepositsAccountTemplate(clientId, productId)) and carries no account-specific data at
// all. This was never read from the right place before, so Edit always opened onto a blank
// picker even for an account that really does have an instruction saved. `.dirty` guards it the
// same way the penal-interest fields above do, so a maker's own in-progress edit here is never
// silently overwritten if this input fires again mid-edit.
if (this.fixedDepositsAccountTemplate && !this.closureSettingsForm.get('maturityInstructionId').dirty) {
this.closureSettingsForm
.get('maturityInstructionId')
?.setValue(this.fixedDepositsAccountTemplate.onAccountClosure?.id ?? '');
const transferControl = this.closureSettingsForm.get('transferToSavingsId');
if (transferControl) {
transferControl.setValue(
this.fixedDepositsAccountTemplate.transferToSavingsId ?? this.linkedSavingsAccountId ?? ''
);
}
}
}
/** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */
/** The linked-savings account for the transfer-to-savings / reinvest-principal-only-interest
* maturity instruction, once picked. */
get selectedTransferAccount(): any {
const id = this.closureSettingsForm.value.transferToSavingsId;
if (!id) return null;
return this.savingsAccountsData.find((account: any) => account.id === id) ?? null;
}
get transferAccountCardTitle(): string {
return this.closureSettingsForm.value.maturityInstructionId === 400
? 'Interest payout account'
: 'Transfer to savings account';
}
get transferAccountCardSubtitle(): string {
return this.closureSettingsForm.value.maturityInstructionId === 400
? 'The principal renews into a new FD; interest earned is paid out to this account.'
: 'Maturity proceeds will be credited to this account.';
}
clearTransferAccount(): void {
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
}
@@ -8,11 +8,24 @@
<div class="pmt">
<div class="pmt__header">
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
<div class="pmt__header-text">
<h2 class="pmt__title">{{ 'labels.heading.Payment' | translate }}</h2>
<p class="pmt__subtitle">Process this Fixed Deposit now -- works whether it's Active or Matured.</p>
<div class="pmt__header-left">
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
<div class="pmt__header-text">
<h2 class="pmt__title">
{{ isDashboardRenewalMode ? 'Renew Fixed Deposit' : ('labels.heading.Payment' | translate) }}
</h2>
<p class="pmt__subtitle">
{{
isDashboardRenewalMode
? 'Renew ' + (dashboardRenewalRow?.accountNo ?? 'this matured FD') + '.'
: 'Process this matured Fixed Deposit now.'
}}
</p>
</div>
</div>
<button type="button" class="pmt__header-close" mat-dialog-close [disabled]="submitting" title="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div mat-dialog-content class="pmt__content">
@@ -27,37 +40,92 @@
</div>
} @else {
<form [formGroup]="paymentForm">
@if (isMaturedRenewal) {
<p class="pmt__hint">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
{{ maturedRenewalTimingText }}
</p>
@if (renewalPreviewLoading) {
<p class="pmt__hint">
<fa-icon icon="spinner" [class.fa-spin]="true" class="m-r-5"></fa-icon>
Calculating gap-period interest...
</p>
} @else if (renewalPreview && !renewalPreview.withinGrace) {
@if (!renewalPreview.linkedAccountId) {
<p class="pmt__hint pmt__hint--warn" role="alert">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This FD has no Deposit Account Link, so there is no account to read a gap-interest rate from. Link a
Savings account to it before renewing late.
</p>
} @else {
<div class="pmt__preview">
<div class="pmt__preview-row">
<span class="pmt__preview-label">FD Maturity Balance (renews as-is below)</span>
<span class="pmt__preview-value">{{ renewalPreview.currentBalance | formatNumber }}</span>
</div>
<div class="pmt__preview-row">
<span class="pmt__preview-label">Gap Days ({{ renewalPreview.maturityDate }} &rarr; today)</span>
<span class="pmt__preview-value">{{ renewalPreview.gapDays }}</span>
</div>
<div class="pmt__preview-row">
<span class="pmt__preview-label">Deposit Account Link</span>
<span class="pmt__preview-value">{{ renewalPreview.linkedAccountNo }}</span>
</div>
<div class="pmt__preview-row">
<span class="pmt__preview-label">Its Interest Rate</span>
<span class="pmt__preview-value">{{ renewalPreview.linkedAccountRate }}%</span>
</div>
<div class="pmt__preview-row pmt__preview-row--highlight pmt__preview-row--total">
<span class="pmt__preview-label"
>Gap-Period Interest &rarr; paid to {{ renewalPreview.linkedAccountNo }}</span
>
<span class="pmt__preview-value">+ {{ renewalPreview.gapInterestAmount | formatNumber }}</span>
</div>
</div>
<p class="pmt__hint">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This gap interest was earned at the linked account's own rate, not the FD's -- it is credited straight
to account {{ renewalPreview.linkedAccountNo }} (the FD's Deposit Account Link), separately from
whatever you choose below. The FD itself renews from its own maturity balance, unchanged.
</p>
}
}
}
@if (reinvestCap !== null && reinvestCap !== undefined) {
<div class="pmt__stat">
<span class="pmt__stat-label">
{{
remainderIsApproximate
? 'Amount available on the selected date'
: ('labels.inputs.Maturity Amount' | translate)
isMaturedRenewal || !remainderIsApproximate
? ('labels.inputs.Maturity Amount' | translate)
: 'Amount available on the selected date'
}}
</span>
<span class="pmt__stat-value">{{ reinvestCap | formatNumber }}</span>
</div>
}
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
matInput
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (paymentForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (!isMaturedRenewal) {
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
matInput
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (paymentForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
<div class="pmt__section-label" id="pmt-action-label">{{ 'labels.inputs.Action' | translate }}</div>
<div class="pmt__options" role="radiogroup" aria-labelledby="pmt-action-label">
@@ -162,10 +230,12 @@
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
@if (!isMaturedRenewal) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
}
@if (submitError) {
<p class="pmt__error" role="alert">
@@ -178,14 +248,25 @@
</div>
<div mat-dialog-actions align="end" class="pmt__actions">
<button mat-button mat-dialog-close [disabled]="submitting">{{ 'labels.buttons.Cancel' | translate }}</button>
<button mat-button mat-dialog-close [disabled]="submitting" class="pmt__btn-cancel">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="primary"
class="pmt__btn-submit"
[disabled]="!paymentForm?.valid || submitting || loadFailed"
(click)="submit()"
>
{{ submitting ? ('labels.buttons.Processing' | translate) : ('labels.buttons.Confirm' | translate) }}
{{
submitting
? isDashboardRenewalMode
? 'Renewing...'
: ('labels.buttons.Processing' | translate)
: isDashboardRenewalMode
? 'Confirm & Renew'
: ('labels.buttons.Confirm' | translate)
}}
</button>
</div>
</div>
@@ -6,291 +6,466 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$navy: #0d1b2a;
$slate: #475569;
$muted: #64748b;
$border: #e2e8f0;
$soft: #f8fafc;
$blue: #1e40af;
$blue-soft: #eff6ff;
$green: #166534;
$green-soft: #f0fdf4;
/**
* Payment / Renewal Dialog Styles
* CBSNexora Core Banking System
*/
.pmt {
display: flex;
flex-direction: column;
min-height: 320px;
max-height: 85vh;
height: 100%;
max-height: 92vh;
box-sizing: border-box;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
color: #0f172a;
border-radius: 16px;
overflow: hidden;
margin: -24px;
box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%);
}
.pmt__header {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 22px 28px 18px;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%);
color: #fff;
flex: 0 0 auto;
}
.pmt__header-ico {
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 10px;
background: rgb(255 255 255 / 15%);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
justify-content: space-between;
gap: 16px;
padding: 26px 30px;
background: linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 55%, #2563eb 100%);
color: #fff;
flex-shrink: 0;
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 88% -20%, rgb(255 255 255 / 22%), transparent 55%);
pointer-events: none;
}
&-left {
display: flex;
align-items: center;
gap: 16px;
position: relative;
z-index: 1;
}
&-ico {
width: 48px;
height: 48px;
border-radius: 14px;
background: rgb(255 255 255 / 16%);
border: 1px solid rgb(255 255 255 / 25%);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #fff;
flex-shrink: 0;
}
&-text {
display: flex;
flex-direction: column;
}
&-close {
position: relative;
z-index: 1;
background: rgb(255 255 255 / 14%);
border: none;
color: #fff;
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
&:hover:not(:disabled) {
background: rgb(255 255 255 / 28%);
transform: rotate(90deg);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.pmt__title {
margin: 0;
font-size: 19px;
font-weight: 700;
font-size: 20px;
font-weight: 800;
color: #fff;
line-height: 1.25;
letter-spacing: -0.01em;
}
.pmt__subtitle {
margin: 4px 0 0;
font-size: 12.5px;
color: rgb(255 255 255 / 82%);
margin: 5px 0 0;
font-size: 13px;
color: rgb(255 255 255 / 88%);
line-height: 1.4;
}
.pmt__content {
padding: 22px 28px 8px !important;
padding: 26px 30px !important;
overflow-y: auto;
flex: 1 1 auto;
background: #fbfcfe;
mat-form-field {
width: 100%;
}
}
.pmt__loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 260px;
min-height: 240px;
}
.pmt__stat {
display: flex;
flex-direction: column;
gap: 2px;
background: $blue-soft;
border: 1px solid #dbeafe;
border-radius: 10px;
padding: 12px 16px;
gap: 4px;
background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%);
border: 1px solid #bfdbfe;
border-radius: 14px;
padding: 18px 22px;
margin-bottom: 18px;
&-label {
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #1d4ed8;
}
&-value {
font-family: 'JetBrains Mono', 'DM Sans', monospace;
font-size: 28px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.01em;
}
}
.pmt__stat-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $blue;
}
.pmt__preview {
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 4px 18px;
margin: 0 0 18px;
.pmt__stat-value {
font-size: 22px;
font-weight: 800;
color: $navy;
font-variant-numeric: tabular-nums;
}
&-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 0;
border-bottom: 1px solid #f1f5f9;
.pmt__date {
width: 100%;
&:last-child {
border-bottom: none;
}
&--highlight {
.pmt__preview-value {
color: #0d9488;
font-weight: 800;
}
}
&--total {
margin: 0 -18px;
padding: 14px 18px;
background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%);
border-bottom: none;
border-radius: 0 0 12px 12px;
.pmt__preview-label {
color: #1d4ed8;
font-weight: 700;
}
.pmt__preview-value {
color: #0f172a;
font-size: 18px;
font-weight: 800;
}
}
}
&-label {
font-size: 12.5px;
color: #64748b;
font-weight: 600;
}
&-value {
font-family: 'JetBrains Mono', 'DM Sans', monospace;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
}
.pmt__section-label {
font-size: 11.5px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $slate;
margin: 4px 0 10px;
letter-spacing: 0.06em;
color: #475569;
margin: 10px 0 12px;
}
/* Action Options Grid - 2 Equal Columns, comfortably wide now that the dialog itself is wider */
.pmt__options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px;
margin-bottom: 6px;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
margin-bottom: 18px;
@media (width <= 560px) {
grid-template-columns: 1fr;
}
}
.pmt__option {
display: flex;
align-items: flex-start;
gap: 10px;
gap: 14px;
text-align: left;
background: #fff;
border: 1.5px solid $border;
border-radius: 10px;
padding: 12px 14px;
border: 1.5px solid #e2e8f0;
border-radius: 14px;
padding: 16px;
cursor: pointer;
font: inherit;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
.pmt__option:hover {
border-color: #93c5fd;
background: $blue-soft;
}
&:hover {
border-color: #93c5fd;
background: #f8fafc;
box-shadow: 0 6px 16px rgb(15 23 42 / 6%);
transform: translateY(-1px);
}
.pmt__option--active {
border-color: $blue;
background: $blue-soft;
box-shadow: 0 0 0 1px $blue;
}
&--active {
border-color: #2563eb;
background: #eff6ff;
box-shadow: 0 0 0 1.5px #2563eb;
.pmt__option-ico {
width: 30px;
height: 30px;
min-width: 30px;
border-radius: 8px;
background: $soft;
color: $blue;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
.pmt__option-ico {
background: #2563eb;
color: #fff;
border-color: #2563eb;
}
}
.pmt__option--active .pmt__option-ico {
background: $blue;
color: #fff;
}
&-ico {
width: 38px;
height: 38px;
min-width: 38px;
border-radius: 10px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
color: #2563eb;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0;
transition: all 0.2s ease;
}
.pmt__option-text {
display: flex;
flex-direction: column;
gap: 2px;
}
// Distinct accent per option while unselected, purely presentational -- helps the four cards read
// apart at a glance. Selecting a card always fills its icon blue (see &--active above), matching the
// blue selection border/check regardless of which option it is, rather than mixing selection-blue
// with the option's own accent.
&:not(.pmt__option--active):nth-child(2) .pmt__option-ico {
color: #7c3aed;
}
.pmt__option-title {
font-size: 13px;
font-weight: 700;
color: $navy;
}
&:not(.pmt__option--active):nth-child(3) .pmt__option-ico {
color: #0d9488;
}
.pmt__option-desc {
font-size: 11px;
color: $muted;
line-height: 1.35;
}
&:not(.pmt__option--active):nth-child(4) .pmt__option-ico {
color: #d97706;
}
.pmt__option:focus-visible {
outline: 2px solid $blue;
outline-offset: 2px;
}
&-text {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.pmt__option-check {
margin-left: auto;
color: $blue;
font-size: 13px;
&-title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
line-height: 1.3;
}
&-desc {
font-size: 12px;
color: #64748b;
line-height: 1.4;
}
&-check {
color: #2563eb;
font-size: 17px;
position: absolute;
top: 14px;
right: 14px;
}
}
.pmt__option-error {
color: #b91c1c;
font-size: 12px;
margin: 4px 0 14px;
}
.pmt__error {
display: flex;
align-items: flex-start;
gap: 4px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #b91c1c;
font-size: 12.5px;
line-height: 1.45;
padding: 10px 14px;
margin: 4px 0 8px;
}
.pmt__hint {
display: flex;
align-items: flex-start;
gap: 4px;
background: $soft;
border: 1px solid $border;
border-radius: 8px;
color: $slate;
font-size: 12px;
line-height: 1.45;
padding: 9px 13px;
margin: 0 0 10px;
}
.pmt__hint--warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
font-weight: 600;
margin: -10px 0 14px;
}
.pmt__split {
display: flex;
align-items: center;
gap: 12px;
background: $soft;
gap: 16px;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 10px;
padding: 14px 16px 0;
margin: 4px 0 8px;
border-radius: 14px;
padding: 18px;
margin: 10px 0 18px;
flex-wrap: wrap;
&-input {
flex: 1 1 220px;
}
&-arrow {
color: #94a3b8;
font-size: 17px;
}
&-remainder {
display: flex;
flex-direction: column;
gap: 3px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 10px;
padding: 12px 16px;
flex: 1 1 220px;
&-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #166534;
}
&-value {
font-family: 'JetBrains Mono', 'DM Sans', monospace;
font-size: 19px;
font-weight: 800;
color: #166534;
}
}
}
.pmt__split-input {
flex: 1 1 180px;
min-width: 160px;
}
.pmt__split-arrow {
color: $muted;
font-size: 14px;
margin-bottom: 20px;
}
.pmt__split-remainder {
.pmt__hint {
display: flex;
flex-direction: column;
gap: 2px;
background: $green-soft;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 8px 14px;
margin-bottom: 14px;
flex: 1 1 180px;
min-width: 160px;
align-items: flex-start;
gap: 10px;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-left: 4px solid #0284c7;
border-radius: 10px;
color: #0369a1;
font-size: 13px;
line-height: 1.5;
padding: 12px 16px;
margin: 0 0 18px;
&--warn {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #d97706;
color: #b45309;
}
}
.pmt__split-remainder-label {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $green;
}
.pmt__split-remainder-value {
font-size: 16px;
font-weight: 800;
color: $green;
font-variant-numeric: tabular-nums;
}
.pmt__content mat-form-field {
width: 100%;
.pmt__error {
display: flex;
align-items: flex-start;
gap: 10px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #ef4444;
border-radius: 10px;
color: #b91c1c;
font-size: 13px;
padding: 12px 16px;
margin: 10px 0;
}
.pmt__actions {
padding: 14px 28px 20px !important;
border-top: 1px solid $border;
flex: 0 0 auto;
padding: 18px 30px !important;
border-top: 1px solid #e2e8f0;
background: #fff;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 14px;
flex-shrink: 0;
.pmt__btn-cancel {
border-radius: 10px;
font-weight: 600;
color: #475569;
}
.pmt__btn-submit {
border-radius: 10px;
font-weight: 700;
padding: 0 26px;
height: 42px;
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
color: #fff;
box-shadow: 0 8px 20px rgb(37 99 235 / 30%);
transition: all 0.2s ease;
&:hover:not(:disabled) {
box-shadow: 0 10px 24px rgb(37 99 235 / 40%);
transform: translateY(-1px);
}
&:disabled {
background: #cbd5e1;
box-shadow: none;
color: #64748b;
}
}
}
@@ -24,15 +24,26 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn,
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FdMaturityRow } from 'app/fd-dashboard/fd-dashboard.compute';
/**
* "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and
* Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice,
* Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the
* entered amount renews on this same account, the rest transfers to a savings account picked here.
*
* Doubles as the FD Dashboard's Renew action when opened with `data.dashboardRenewal` set: same form,
* same full set of closure types (nothing removed -- Transfer to Savings and Partial Reinvest work from
* the Dashboard too, not just the two Reinvest options). The only two differences in that mode: the
* Closed On Date is computed from the FD's grace window instead of picked freely (backdated to the
* original maturity date within grace, today once it's passed -- the picker is hidden, not just
* defaulted, so it can't accidentally be moved off that date), and submit() first calls
* `creditGapInterestIfPastGrace` when past grace, crediting the gap-period interest (at the linked
* account's rate) onto the FD before the normal `payment` command runs and reads its balance.
*/
@Component({
selector: 'mifosx-payment-dialog',
@@ -76,11 +87,18 @@ export class PaymentDialogComponent implements OnInit {
};
dialogRef = inject<MatDialogRef<PaymentDialogComponent>>(MatDialogRef);
data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA);
data: {
accountId: number | string;
accountBalance?: number;
maturityDate?: any;
/** Present only when opened from the FD Dashboard's Renew action. */
dashboardRenewal?: { row: FdMaturityRow };
} = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private alertService = inject(AlertService);
paymentForm: UntypedFormGroup;
/** Loaded once, on open, from the same closure template the existing Close action already uses. */
@@ -93,6 +111,16 @@ export class PaymentDialogComponent implements OnInit {
submitError: string = null;
maxDate: Date;
/** Whether gap-interest still needs crediting before this submits -- only ever true once, right
* after open; cleared as soon as `submit()` has credited it, so a retried submit doesn't double-post. */
private gapInterestPending = false;
/** Read-only numbers (current balance, linked account's rate, gap days, gap-interest amount,
* projected new balance) fetched once on open in dashboard-renewal mode, shown above the Action
* cards so staff see exactly what a late renewal is about to credit before confirming anything. */
renewalPreview: any = null;
renewalPreviewLoading = false;
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.paymentForm = this.formBuilder.group({
@@ -106,6 +134,34 @@ export class PaymentDialogComponent implements OnInit {
],
note: ['']
});
// Fetched every time this dialog opens, not just from the FD Dashboard -- Payment is now only ever
// offered once an FD has Matured (see FixedDepositsButtonsConfiguration), so a plain Payment on it
// is exactly the same "late renewal" scenario the Dashboard's Renew action handles, and needs the
// exact same date-locking/gap-interest treatment, or an FD closed this way instead of from the
// Dashboard would silently skip crediting interest it genuinely earned.
this.renewalPreviewLoading = true;
this.fixedDepositsService.getFdRenewalPreview(this.data.accountId).subscribe({
next: (preview: any) => {
this.renewalPreview = preview;
this.renewalPreviewLoading = false;
if (preview?.isMatured) {
// Computed from the grace window, never picked -- the date field stays in the form (the
// generic submit path and reinvestCap/processingOnOrAfterMaturity both key off it) but its
// picker is hidden once matured (see the template), so it can't be moved off this value.
this.paymentForm
.get('closedOnDate')
.setValue(preview.withinGrace ? new Date(preview.maturityDate) : this.maxDate);
this.gapInterestPending = !preview.withinGrace;
}
},
// Non-fatal -- Confirm still works from here (backend re-derives the same numbers on submit),
// only the numeric preview and its date-locking are unavailable, not the action itself.
error: () => {
this.renewalPreviewLoading = false;
}
});
this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
// The cap depends on whether the chosen date is on or after maturity, so a date change can make an
// already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date.
@@ -136,6 +192,31 @@ export class PaymentDialogComponent implements OnInit {
});
}
get isDashboardRenewalMode(): boolean {
return !!this.data.dashboardRenewal;
}
get dashboardRenewalRow(): FdMaturityRow | null {
return this.data.dashboardRenewal?.row ?? null;
}
/** True once the loaded preview confirms this FD has actually Matured -- which, since Payment is
* only ever offered on a Matured account, is every real open of this dialog now, from either entry
* point. Drives the date-locking and gap-interest preview/crediting identically either way. */
get isMaturedRenewal(): boolean {
return !!this.renewalPreview?.isMatured;
}
/** What clicking Confirm is actually about to do -- shown above the (hidden-picker) date once
* matured, since the date itself is no longer visible to explain it. */
get maturedRenewalTimingText(): string {
if (!this.renewalPreview) return '';
return this.renewalPreview.withinGrace
? `Backdated to its original maturity date (${this.renewalPreview.maturityDate}), at the currently active interest rate.`
: `This FD's grace period has passed -- gap-period interest (for the days since maturity) will be ` +
`credited at the linked account's rate first, then this renews from today.`;
}
/**
* The amount being split, and the bound the entered partial amount is checked against.
* <p>
@@ -259,6 +340,31 @@ export class PaymentDialogComponent implements OnInit {
}
submit(): void {
if (this.gapInterestPending) {
this.submitting = true;
this.submitError = null;
this.fixedDepositsService.creditGapInterestIfPastGrace(this.data.accountId).subscribe({
next: () => {
this.gapInterestPending = false;
this.submitGenericPayment();
},
error: (error: any) => {
this.submitting = false;
this.submitError =
error?.error?.defaultUserMessage ??
error?.error?.errors?.[0]?.defaultUserMessage ??
'Could not credit gap-period interest. Please review the details and try again.';
}
});
return;
}
this.submitGenericPayment();
}
/** The same `command=payment` submission every non-Dashboard call to this dialog already used --
* Dashboard-renewal mode reaches it too, once any gap-interest pre-step above has cleared, so every
* closure type behaves identically regardless of where the dialog was opened from. */
private submitGenericPayment(): void {
const formValue = { ...this.paymentForm.value };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
@@ -271,7 +377,16 @@ export class PaymentDialogComponent implements OnInit {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data)
.subscribe({
next: () => {
next: (response: any) => {
// Parked in the Checker Inbox rather than executed -- see the same handling in
// PrematureCloseDialogComponent for why an HTTP 200 here can still mean "not applied yet".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'This payment has been submitted for approval. The deposit is unchanged until a checker approves it.'
});
}
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
@@ -0,0 +1,282 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pcd">
<div class="pcd__header">
<div class="pcd__header-left">
<span class="pcd__header-ico"><fa-icon icon="times-circle"></fa-icon></span>
<div class="pcd__header-text">
<h2 class="pcd__title">{{ 'labels.heading.Premature Close' | translate }}</h2>
<p class="pcd__subtitle">
Close {{ data.accountNo ?? 'this account' }} before maturity -- interest is recalculated as of the date below,
usually at a lower, penalty rate.
</p>
</div>
</div>
<button type="button" class="pcd__header-close" mat-dialog-close [disabled]="submitting" title="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<div mat-dialog-content class="pcd__content">
@if (loadFailed) {
<div class="pcd__error" role="alert">
<fa-icon icon="times-circle" class="m-r-10"></fa-icon>
Could not calculate the premature-closure amount for this account. Close this dialog and try again.
</div>
} @else {
<form [formGroup]="prematureCloseForm">
<mat-form-field appearance="outline" class="pcd__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Premature Close Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (prematureCloseForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Premature Close Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Closure Reason</mat-label>
<mat-select formControlName="prematureClosureReasonId">
@for (reason of closureReasonOptions; track reason.id) {
<mat-option [value]="reason.id">{{ reason.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loadingAmount) {
<div class="pcd__loading">
<mat-spinner diameter="28"></mat-spinner>
<span>Calculating premature-closure amount...</span>
</div>
} @else {
@if (data.activatedOnDate || data.maturityDate || data.currentBalance !== undefined) {
<div class="pcd__preview pcd__preview--tenure">
@if (data.activatedOnDate) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Current Term Started</span>
<span class="pcd__preview-value">{{ activatedOnDateDisplay }}</span>
</div>
<div class="pcd__preview-row">
<span class="pcd__preview-label">Tenure Completed (this term)</span>
<span class="pcd__preview-value"
>{{ tenureElapsedDisplay }} <span class="pcd__preview-sub">({{ daysElapsed }} days)</span></span
>
</div>
}
@if (data.maturityDate) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Scheduled Maturity Date</span>
<span class="pcd__preview-value"
>{{ maturityDateDisplay }}
<span class="pcd__preview-sub">({{ daysRemainingToMaturity }} days left)</span></span
>
</div>
}
@if (data.currentBalance !== undefined && data.currentBalance !== null) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Current Balance (as of today)</span>
<span class="pcd__preview-value">{{ data.currentBalance | formatNumber }}</span>
</div>
}
</div>
}
@if (maturityAmount !== null && maturityAmount !== undefined) {
<div class="pcd__preview">
@if (data.depositAmount) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Deposit Amount</span>
<span class="pcd__preview-value">{{ data.depositAmount | formatNumber }}</span>
</div>
}
@if (data.nominalAnnualInterestRate) {
<div class="pcd__preview-row">
<span class="pcd__preview-label">Contracted Interest Rate</span>
<span class="pcd__preview-value">{{ data.nominalAnnualInterestRate }}%</span>
</div>
}
@if (effectiveInterestRate !== null && effectiveInterestRate !== undefined) {
<div class="pcd__preview-row pcd__preview-row--highlight">
<span class="pcd__preview-label">Effective Rate for this closure</span>
<span class="pcd__preview-value">{{ effectiveInterestRate }}%</span>
</div>
}
@if (data.preClosurePenalApplicable) {
<div
class="pcd__preview-row"
[class.pcd__preview-row--waived]="prematureCloseForm.get('waivePreClosurePenalty').value"
>
<span class="pcd__preview-label"
>Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }})</span
>
<span class="pcd__preview-value">
@if (prematureCloseForm.get('waivePreClosurePenalty').value) {
<span class="pcd__penalty-waived-tag">Waived</span>
} @else {
&minus; {{ data.preClosurePenalInterest }}%
}
</span>
</div>
}
@if (interestEarned !== null) {
<div class="pcd__preview-row pcd__preview-row--highlight">
<span class="pcd__preview-label"
>Interest Earned (to {{ 'labels.inputs.Premature Close Date' | translate }})</span
>
<span class="pcd__preview-value"
>{{ interestEarned >= 0 ? '+' : '' }} {{ interestEarned | formatNumber }}</span
>
</div>
}
<div class="pcd__preview-row pcd__preview-row--total">
<span class="pcd__preview-label">{{ 'labels.inputs.Maturity Amount' | translate }}</span>
<span class="pcd__preview-value">{{ maturityAmount | formatNumber }}</span>
</div>
</div>
}
@if (data.preClosurePenalApplicable) {
@if (isAutoWaivedReason) {
<div class="pcd__waive-locked">
<fa-icon icon="check-circle" class="m-r-10"></fa-icon>
<span>
Penalty automatically waived -- <strong>{{ selectedReasonLabel }}</strong> always waives the
pre-closure penalty, regardless of this checkbox.
</span>
</div>
} @else {
<mat-checkbox formControlName="waivePreClosurePenalty" class="pcd__waive-checkbox">
Waive the pre-closure penalty for this closure
<span class="pcd__waive-hint">
Interest recalculates at the full contracted rate instead of the penalty rate above. This does not
change the product's own penalty setting -- it applies to this one closure only.
</span>
</mat-checkbox>
}
}
<div class="pcd__section-label" id="pcd-action-label">{{ 'labels.inputs.Action' | translate }}</div>
<div class="pcd__options" role="radiogroup" aria-labelledby="pcd-action-label">
@for (closureOption of onAccountClosureOptions; track closureOption) {
<button
type="button"
class="pcd__option"
role="radio"
[attr.aria-checked]="prematureCloseForm.get('onAccountClosureId').value === closureOption.id"
[class.pcd__option--active]="prematureCloseForm.get('onAccountClosureId').value === closureOption.id"
(click)="selectClosureType(closureOption.id)"
>
<span class="pcd__option-ico"><fa-icon [icon]="iconFor(closureOption.id)"></fa-icon></span>
<span class="pcd__option-text">
<span class="pcd__option-title">{{ closureOption.value }}</span>
<span class="pcd__option-desc">{{ descriptionFor(closureOption.id) }}</span>
</span>
@if (prematureCloseForm.get('onAccountClosureId').value === closureOption.id) {
<fa-icon icon="check-circle" class="pcd__option-check"></fa-icon>
}
</button>
}
</div>
@if (
prematureCloseForm.controls.onAccountClosureId?.hasError('required') &&
prematureCloseForm.controls.onAccountClosureId?.touched
) {
<div class="pcd__option-error">
{{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.commons.required' | translate }}
</div>
}
@if (noDestinationAccounts) {
<p class="pcd__hint pcd__hint--warn" role="alert">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This customer has no active savings account to transfer into. Premature closure has to be paid into a
savings account, so open one for this customer before closing this deposit.
</p>
}
@if (prematureCloseForm.contains('toSavingsAccountId')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
{{ account.accountNo }}
</mat-option>
}
</mat-select>
@if (prematureCloseForm.controls.toSavingsAccountId?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (prematureCloseForm.contains('toGlAccountId')) {
<mifosx-gl-account-selector
[inputFormControl]="toGlAccountControl"
[glAccountList]="glAccountsData"
[required]="true"
[inputLabel]="'Transfer to GL Account'"
>
</mifosx-gl-account-selector>
}
@if (prematureCloseForm.contains('transferDescription')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
}
@if (prematureCloseForm.contains('note')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
}
}
@if (submitError) {
<p class="pcd__error" role="alert">
<fa-icon icon="times-circle" class="m-r-5"></fa-icon>
{{ submitError }}
</p>
}
</form>
}
</div>
<div mat-dialog-actions align="end" class="pcd__actions">
<button mat-button mat-dialog-close [disabled]="submitting" class="pcd__btn-cancel">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
class="pcd__btn-submit"
[disabled]="!prematureCloseForm?.valid || submitting || loadFailed || loadingAmount"
(click)="submit()"
>
{{ submitting ? ('labels.buttons.Processing' | translate) : ('labels.buttons.Confirm' | translate) }}
</button>
</div>
</div>
@@ -0,0 +1,468 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Premature Close Dialog Styles
* CBSNexora Core Banking System
*
* Same shape/spacing/typography as the Payment dialog (.pmt__*), in this Danger Zone action's own red
* accent on the header and Confirm button, rather than Payment's blue -- an irreversible, usually
* penalty-bearing action reads differently from a routine one, everywhere else stays identical so the
* two dialogs feel like the same product.
*/
.pcd {
display: flex;
flex-direction: column;
height: 100%;
max-height: 92vh;
box-sizing: border-box;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
color: #0f172a;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%);
}
.pcd__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 26px 30px;
background: linear-gradient(135deg, #7f1d1d 0%, #b91c1c 55%, #dc2626 100%);
color: #fff;
flex-shrink: 0;
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 88% -20%, rgb(255 255 255 / 22%), transparent 55%);
pointer-events: none;
}
&-left {
display: flex;
align-items: center;
gap: 16px;
position: relative;
z-index: 1;
}
&-ico {
width: 48px;
height: 48px;
border-radius: 14px;
background: rgb(255 255 255 / 16%);
border: 1px solid rgb(255 255 255 / 25%);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #fff;
flex-shrink: 0;
}
&-text {
display: flex;
flex-direction: column;
}
&-close {
position: relative;
z-index: 1;
background: rgb(255 255 255 / 14%);
border: none;
color: #fff;
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
&:hover:not(:disabled) {
background: rgb(255 255 255 / 28%);
transform: rotate(90deg);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.pcd__title {
margin: 0;
font-size: 20px;
font-weight: 800;
color: #fff;
line-height: 1.25;
letter-spacing: -0.01em;
}
.pcd__subtitle {
margin: 5px 0 0;
font-size: 13px;
color: rgb(255 255 255 / 88%);
line-height: 1.4;
}
.pcd__content {
padding: 26px 30px !important;
overflow-y: auto;
flex: 1 1 auto;
background: #fbfcfe;
mat-form-field {
width: 100%;
}
}
.pcd__loading {
display: flex;
align-items: center;
gap: 12px;
color: #64748b;
font-size: 13px;
padding: 14px 0 18px;
}
.pcd__preview {
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 4px 18px;
margin: 0 0 18px;
&-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 0;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
&--highlight {
.pcd__preview-value {
color: #b91c1c;
font-weight: 800;
}
}
&--total {
margin: 0 -18px;
padding: 14px 18px;
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
border-bottom: none;
border-radius: 0 0 12px 12px;
.pcd__preview-label {
color: #b91c1c;
font-weight: 700;
}
.pcd__preview-value {
color: #0f172a;
font-size: 18px;
font-weight: 800;
}
}
}
&-label {
font-size: 12.5px;
color: #64748b;
font-weight: 600;
}
&-value {
font-family: 'JetBrains Mono', 'DM Sans', monospace;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&-sub {
font-family: 'DM Sans', sans-serif;
font-size: 11px;
font-weight: 500;
color: #94a3b8;
margin-left: 4px;
}
}
.pcd__preview--tenure {
margin-bottom: 14px;
}
.pcd__preview-row--waived {
.pcd__preview-label {
color: #16a34a;
}
}
.pcd__penalty-waived-tag {
font-family: 'DM Sans', sans-serif;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #15803d;
background: #dcfce7;
border: 1px solid #86efac;
border-radius: 999px;
padding: 3px 10px;
}
.pcd__waive-checkbox {
display: flex;
align-items: flex-start;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 14px 16px;
margin: 0 0 18px;
font-size: 13px;
font-weight: 600;
color: #0f172a;
::ng-deep .mdc-form-field {
align-items: flex-start;
}
::ng-deep .mdc-checkbox {
margin-top: -9px;
}
}
.pcd__waive-locked {
display: flex;
align-items: flex-start;
gap: 10px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-left: 4px solid #16a34a;
border-radius: 10px;
color: #15803d;
font-size: 13px;
line-height: 1.5;
padding: 12px 16px;
margin: 0 0 18px;
strong {
color: #166534;
}
}
.pcd__waive-hint {
display: block;
font-size: 12px;
font-weight: 500;
color: #64748b;
margin-top: 3px;
line-height: 1.4;
}
.pcd__section-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #475569;
margin: 10px 0 12px;
}
/* Action Options Grid - 2 Equal Columns */
.pcd__options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
margin-bottom: 18px;
@media (width <= 560px) {
grid-template-columns: 1fr;
}
}
.pcd__option {
display: flex;
align-items: flex-start;
gap: 14px;
text-align: left;
background: #fff;
border: 1.5px solid #e2e8f0;
border-radius: 14px;
padding: 16px;
cursor: pointer;
font: inherit;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&:hover {
border-color: #fca5a5;
background: #f8fafc;
box-shadow: 0 6px 16px rgb(15 23 42 / 6%);
transform: translateY(-1px);
}
&--active {
border-color: #dc2626;
background: #fef2f2;
box-shadow: 0 0 0 1.5px #dc2626;
.pcd__option-ico {
background: #dc2626;
color: #fff;
border-color: #dc2626;
}
}
&-ico {
width: 38px;
height: 38px;
min-width: 38px;
border-radius: 10px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
color: #dc2626;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0;
transition: all 0.2s ease;
}
&-text {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
&-title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
line-height: 1.3;
}
&-desc {
font-size: 12px;
color: #64748b;
line-height: 1.4;
}
&-check {
color: #dc2626;
font-size: 17px;
position: absolute;
top: 14px;
right: 14px;
}
}
.pcd__option-error {
color: #b91c1c;
font-size: 12px;
font-weight: 600;
margin: -10px 0 14px;
}
.pcd__hint {
display: flex;
align-items: flex-start;
gap: 10px;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-left: 4px solid #0284c7;
border-radius: 10px;
color: #0369a1;
font-size: 13px;
line-height: 1.5;
padding: 12px 16px;
margin: 0 0 18px;
&--warn {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #d97706;
color: #b45309;
}
}
.pcd__error {
display: flex;
align-items: flex-start;
gap: 10px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #ef4444;
border-radius: 10px;
color: #b91c1c;
font-size: 13px;
padding: 12px 16px;
margin: 10px 0;
}
.pcd__actions {
padding: 18px 30px !important;
border-top: 1px solid #e2e8f0;
background: #fff;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 14px;
flex-shrink: 0;
.pcd__btn-cancel {
border-radius: 10px;
font-weight: 600;
color: #475569;
}
.pcd__btn-submit {
border-radius: 10px;
font-weight: 700;
padding: 0 26px;
height: 42px;
background: linear-gradient(135deg, #b91c1c 0%, #dc2626 100%);
color: #fff;
box-shadow: 0 8px 20px rgb(220 38 38 / 30%);
transition: all 0.2s ease;
&:hover:not(:disabled) {
box-shadow: 0 10px 24px rgb(220 38 38 / 40%);
transform: translateY(-1px);
}
&:disabled {
background: #cbd5e1;
box-shadow: none;
color: #64748b;
}
}
}
@@ -0,0 +1,446 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, Validators } from '@angular/forms';
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { GlAccountSelectorComponent } from 'app/shared/accounting/gl-account-selector/gl-account-selector.component';
import { GLAccount } from 'app/shared/models/general.model';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* "Premature Close" dialog -- Active-only, no reinvest option (the deposit hasn't matured, so there's
* nothing to renew): transfer the pre-maturity balance to a savings account, or post it to a GL account.
* There is deliberately no cash payout -- the backend does not offer or accept one for a premature
* closure, so the proceeds always stay traceable to an account. Same
* `.pcd__*` visual language as the Payment dialog ({@link PaymentDialogComponent}), in this Danger Zone
* action's own red accent rather than Payment's blue, since it's an irreversible, usually
* penalty-bearing action rather than a routine one.
* <p>
* The premature amount is date-dependent (unlike Payment's one-shot template load): every change to
* Closed On Date re-calls `calculatePrematureAmount`, same as the full-page form this replaces did.
*/
@Component({
selector: 'mifosx-premature-close-dialog',
templateUrl: './premature-close-dialog.component.html',
styleUrls: ['./premature-close-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatDialogClose,
CdkTextareaAutosize,
FaIconComponent,
FormatNumberPipe,
GlAccountSelectorComponent
]
})
export class PrematureCloseDialogComponent implements OnInit {
/** The `onAccountClosureId` codes Premature Close can offer -- same codes Payment/Close use. */
private static readonly WITHDRAW_DEPOSIT_ID = 100;
private static readonly TRANSFER_TO_SAVINGS_ID = 200;
private static readonly TRANSFER_TO_GL_ID = 600;
private static readonly ACTION_ICONS: Record<number, string> = {
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave',
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet',
[PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'book'
};
private static readonly ACTION_DESCRIPTIONS: Record<number, string> = {
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash',
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account',
[PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'Post the full balance to a GL account'
};
/**
* Mirrors the backend's PrematureClosureReasonType values exactly -- see that enum for why these
* four (everything but Normal) always waive the penalty by RBI policy rather than at the maker's
* discretion. `gatedBy` is the product-facility flag (from `data`) that must be true for the option
* to even be offered; `null` means always offered. Deceased Settlement / Nominee-Legal Heir Closure
* are gated because they are the product's own opt-in facilities (see SavingsProduct
* deceasedSettlement/noPenaltyNomineeHeirClosure) -- a product that never enabled one shouldn't let
* a maker pick it to bypass a penalty that product's terms don't actually exempt.
*/
private static readonly NORMAL_REASON_ID = 100;
private static readonly ALL_CLOSURE_REASONS: {
id: number;
label: string;
autoWaived: boolean;
gatedBy: 'deceasedSettlement' | 'noPenaltyNomineeHeirClosure' | null;
}[] = [
{ id: 100, label: 'Normal Premature Closure', autoWaived: false, gatedBy: null },
{ id: 200, label: 'Premature Extension / Renewal', autoWaived: true, gatedBy: null },
{ id: 300, label: 'Conversion to Another Term Deposit', autoWaived: true, gatedBy: null },
{ id: 400, label: 'Deceased Settlement', autoWaived: true, gatedBy: 'deceasedSettlement' },
{ id: 500, label: 'Nominee / Legal Heir Closure', autoWaived: true, gatedBy: 'noPenaltyNomineeHeirClosure' }
];
dialogRef = inject<MatDialogRef<PrematureCloseDialogComponent>>(MatDialogRef);
data: {
accountId: number | string;
accountNo?: string;
depositAmount?: number;
nominalAnnualInterestRate?: number;
preClosurePenalApplicable?: boolean;
preClosurePenalInterest?: number;
preClosurePenalInterestOnType?: { value: string };
deceasedSettlement?: boolean;
noPenaltyNomineeHeirClosure?: boolean;
activatedOnDate?: any;
maturityDate?: any;
currentBalance?: number;
} = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private accountingService = inject(AccountingService);
private alertService = inject(AlertService);
private dateUtils = inject(Dates);
prematureCloseForm: UntypedFormGroup;
onAccountClosureOptions: any[] = [];
savingsAccountsData: any[] = [];
/** Postable GL accounts, loaded once -- only read when the GL action is chosen. */
glAccountsData: GLAccount[] = [];
maturityAmount: number = null;
/** The rate actually resolved for THIS preview by the backend (actual-period vs contracted,
* penalty-deducted or not) -- distinct from data.nominalAnnualInterestRate, which is always the
* account's unmodified contracted rate. Read fresh on every recalculation. */
effectiveInterestRate: number = null;
loadingAmount = true;
loadFailed = false;
submitting = false;
submitError: string = null;
minDate = new Date(2000, 0, 1);
maxDate: Date;
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.prematureCloseForm = this.formBuilder.group({
closedOnDate: [
this.maxDate,
Validators.required
],
// Both always present (not added conditionally like onAccountClosureId below) so submit()
// always carries a value, even for a product with no penalty configured at all -- the backend
// ignores them there anyway, since there is nothing to waive.
prematureClosureReasonId: [PrematureCloseDialogComponent.NORMAL_REASON_ID],
waivePreClosurePenalty: [false]
});
this.prematureCloseForm
.get('closedOnDate')
.valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date));
// Waiving/un-waiving recalculates the preview the same way changing the date does -- the maker
// should see the actual amount they're about to confirm, not the penalty-applied one, the moment
// they tick the box.
this.prematureCloseForm
.get('waivePreClosurePenalty')
.valueChanges.subscribe(() => this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value));
// Picking an RBI-mandated waiver reason forces the checkbox on (see onClosureReasonChange) and,
// like it, recalculates the preview so the amount shown always matches the reason selected.
this.prematureCloseForm
.get('prematureClosureReasonId')
.valueChanges.subscribe((id: number) => this.onClosureReasonChange(id));
// Same date the form already starts with -- without this the amount/options never load unless the
// maker happens to touch the date picker, which the full-page form this replaces required too.
this.calculatePrematureAmount(this.maxDate);
}
/** The reason list this account's product actually allows -- see ALL_CLOSURE_REASONS' `gatedBy`. */
get closureReasonOptions(): { id: number; label: string; autoWaived: boolean }[] {
return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.filter(
(reason) => !reason.gatedBy || this.data[reason.gatedBy]
);
}
/** True when the selected reason waives the penalty by policy, not maker choice -- the checkbox is
* hidden in favour of a plain statement of that fact when this is true (see the template). */
get isAutoWaivedReason(): boolean {
return !!this.selectedReason?.autoWaived;
}
get selectedReasonLabel(): string {
return this.selectedReason?.label ?? '';
}
private get selectedReason(): { id: number; label: string; autoWaived: boolean } | undefined {
const id = this.prematureCloseForm?.get('prematureClosureReasonId')?.value;
return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((reason) => reason.id === id);
}
private onClosureReasonChange(reasonId: number): void {
const reason = PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((r) => r.id === reasonId);
if (reason?.autoWaived) {
// emitEvent: false -- the explicit calculatePrematureAmount call below already covers this
// change; without it, this setValue would also fire waivePreClosurePenalty's own subscription
// and the amount would be calculated twice for one reason change.
this.prematureCloseForm.get('waivePreClosurePenalty').setValue(true, { emitEvent: false });
}
this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value);
}
private calculatePrematureAmount(date: Date): void {
this.loadingAmount = true;
this.loadFailed = false;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const data = {
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
dateFormat,
locale,
prematureClosureReasonId: this.prematureCloseForm.get('prematureClosureReasonId')?.value,
waivePreClosurePenalty: !!this.prematureCloseForm.get('waivePreClosurePenalty')?.value
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data)
.subscribe({
next: (response: any) => {
this.savingsAccountsData = response.savingsAccounts ?? [];
this.onAccountClosureOptions = response.onAccountClosureOptions ?? [];
this.maturityAmount = response.maturityAmount;
this.effectiveInterestRate = response.nominalAnnualInterestRate ?? null;
this.loadingAmount = false;
if (!this.prematureCloseForm.contains('onAccountClosureId')) {
this.prematureCloseForm.addControl('onAccountClosureId', new UntypedFormControl('', Validators.required));
this.prematureCloseForm.addControl('note', new UntypedFormControl(''));
this.prematureCloseForm
.get('onAccountClosureId')
.valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
}
},
// Without this the spinner would run forever on a failed load, leaving Cancel as the only way out.
error: () => {
this.loadingAmount = false;
this.loadFailed = true;
}
});
}
/** Adds/removes the destination control to match what the chosen type needs. */
private onClosureTypeChange(id: number): void {
const toSavings = id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID;
const toGl = id === PrematureCloseDialogComponent.TRANSFER_TO_GL_ID;
if (toSavings && !this.prematureCloseForm.contains('toSavingsAccountId')) {
this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
} else if (!toSavings) {
this.prematureCloseForm.removeControl('toSavingsAccountId');
}
if (toGl) {
if (!this.prematureCloseForm.contains('toGlAccountId')) {
this.prematureCloseForm.addControl('toGlAccountId', new UntypedFormControl('', Validators.required));
}
this.loadGlAccounts();
} else {
this.prematureCloseForm.removeControl('toGlAccountId');
}
// Both destinations carry the description through -- to the account transfer, or to the narration on
// the journal entry that credits the GL account.
if ((toSavings || toGl) && !this.prematureCloseForm.contains('transferDescription')) {
this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl(''));
} else if (!toSavings && !toGl) {
this.prematureCloseForm.removeControl('transferDescription');
}
}
/** Fetched on first use rather than with the dialog, since most closures never pick the GL action. */
private loadGlAccounts(): void {
if (this.glAccountsData.length) {
return;
}
this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
this.glAccountsData = accounts ?? [];
});
}
/** The GL selector takes a control instance, not a control name. */
get toGlAccountControl(): UntypedFormControl {
return this.prematureCloseForm.get('toGlAccountId') as UntypedFormControl;
}
/** The gap between the deposit and the computed maturity amount -- the actual interest this premature
* closure earns, at whatever rate (contracted, or the lower pre-closure-penalty one below) applied. */
get interestEarned(): number | null {
if (this.maturityAmount === null || this.maturityAmount === undefined || !this.data.depositAmount) {
return null;
}
return Number(this.maturityAmount) - Number(this.data.depositAmount);
}
get activatedOnDateDisplay(): string {
return this.formatFullDate(this.data.activatedOnDate);
}
get maturityDateDisplay(): string {
return this.formatFullDate(this.data.maturityDate);
}
/**
* The date every "as of" figure in this dialog is measured against -- the maker's own chosen Premature
* Close Date, falling back to Fineract's configured Business Date. Deliberately NOT the real-world
* clock (`new Date()`): Fineract's business date can lag behind the calendar (a bank's own admin
* advances it), and the backend's interest calculation is measured from THIS date, not today's actual
* date. Using the real clock here previously showed a "days elapsed" figure that disagreed with the
* interest the backend actually computed for the same closure -- both must read off the same clock.
*/
private get referenceDate(): Date {
const picked = this.prematureCloseForm?.get('closedOnDate')?.value;
return picked instanceof Date ? picked : this.maxDate;
}
/** Days from the current term's start to the chosen closure date -- 0 if the date is missing or the
* closure date is somehow before it, rather than a negative. */
get daysElapsed(): number | null {
const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
if (!start) {
return null;
}
return Math.max(0, this.dateUtils.calculateDiff(start, this.referenceDate));
}
/** Days from the chosen closure date to the scheduled maturity date -- 0 once matured/overdue rather
* than negative, since this dialog is for a still-Active account closing early, not a matured one. */
get daysRemainingToMaturity(): number | null {
const maturity = this.data.maturityDate ? this.dateUtils.parseDate(this.data.maturityDate) : null;
if (!maturity) {
return null;
}
return Math.max(0, this.dateUtils.calculateDiff(this.referenceDate, maturity));
}
/** Elapsed tenure as Years/Months/Days, the way a real passbook states it, rather than a raw day
* count that is hard to read for a multi-year FD. */
get tenureElapsedDisplay(): string {
const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
if (!start) {
return '—';
}
const { years, months, days } = PrematureCloseDialogComponent.yearsMonthsDaysBetween(start, this.referenceDate);
const parts: string[] = [];
if (years > 0) {
parts.push(`${years} yr${years === 1 ? '' : 's'}`);
}
if (months > 0) {
parts.push(`${months} mo${months === 1 ? '' : 's'}`);
}
if (days > 0 || parts.length === 0) {
parts.push(`${days} day${days === 1 ? '' : 's'}`);
}
return parts.join(', ');
}
private formatFullDate(value: any): string {
if (!value) {
return '—';
}
const parsed = this.dateUtils.parseDate(value);
// "28 September 2026" -- day/month-name/year, so this reads unambiguously regardless of the
// account's own configured short date format elsewhere in the app.
return this.dateUtils.formatDate(parsed, 'd MMMM y');
}
private static yearsMonthsDaysBetween(start: Date, end: Date): { years: number; months: number; days: number } {
let years = end.getFullYear() - start.getFullYear();
let months = end.getMonth() - start.getMonth();
let days = end.getDate() - start.getDate();
if (days < 0) {
months -= 1;
const daysInPreviousMonth = new Date(end.getFullYear(), end.getMonth(), 0).getDate();
days += daysInPreviousMonth;
}
if (months < 0) {
years -= 1;
months += 12;
}
return { years: Math.max(0, years), months: Math.max(0, months), days: Math.max(0, days) };
}
/** True when the chosen action needs a destination account but the customer has none to pick. */
get noDestinationAccounts(): boolean {
return (
!this.loadingAmount &&
this.prematureCloseForm?.contains('toSavingsAccountId') &&
this.savingsAccountsData.length === 0
);
}
iconFor(id: number): string {
return PrematureCloseDialogComponent.ACTION_ICONS[id] ?? 'circle';
}
descriptionFor(id: number): string {
return PrematureCloseDialogComponent.ACTION_DESCRIPTIONS[id] ?? '';
}
selectClosureType(id: number): void {
this.prematureCloseForm.get('onAccountClosureId').setValue(id);
this.prematureCloseForm.get('onAccountClosureId').markAsTouched();
}
submit(): void {
const formValue = { ...this.prematureCloseForm.value };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
if (formValue.closedOnDate instanceof Date) {
formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat);
}
const data = { ...formValue, dateFormat, locale };
this.submitting = true;
this.submitError = null;
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'prematureClose', data)
.subscribe({
next: (response: any) => {
// A command on a maker-checker permission is parked in the Checker Inbox, not executed:
// the platform rolls its transaction back and still answers HTTP 200 with
// rollbackTransaction: true. Without saying so, the dialog closes and the account reloads
// unchanged -- which reads as "nothing happened" rather than "waiting for approval".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Premature closure has been submitted for approval. The deposit is unchanged until a checker approves it.'
});
}
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
// permanently disabled Confirm behind whatever modal the global interceptor threw up.
error: (error: any) => {
this.submitting = false;
this.submitError =
error?.error?.defaultUserMessage ??
error?.error?.errors?.[0]?.defaultUserMessage ??
'This account could not be prematurely closed. Please review the details and try again.';
}
});
}
}
@@ -17,6 +17,7 @@ import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-int
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component';
import { PrematureCloseDialogComponent } from './custom-dialogs/premature-close-dialog/premature-close-dialog.component';
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
/** Custom Button Config. */
@@ -29,6 +30,7 @@ import { AccountsService } from 'app/accounts/accounts.service';
import { Currency } from 'app/shared/models/general.model';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, CurrencyPipe } from '@angular/common';
@@ -73,6 +75,7 @@ import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dor
export class FixedDepositAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
private fixedDepositsService = inject(FixedDepositsService);
private savingsService = inject(SavingsService);
private accountsService = inject(AccountsService);
@@ -238,7 +241,6 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Undo Activation':
case 'Add Charge':
case 'Withdrawn by Client':
case 'Premature Close':
case 'Deposit':
case 'Withdrawal':
this.router.navigate([`actions/${name}`], { relativeTo: this.route });
@@ -267,16 +269,21 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Payment':
this.openPaymentDialog();
break;
case 'Premature Close':
this.openPrematureCloseDialog();
break;
}
}
/**
* Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or
* Matured, and is the only place a Partial Reinvest can be done.
* Opens the Payment action -- only offered once the FD has actually Matured (see
* FixedDepositsButtonsConfiguration), and the only place a Partial Reinvest can be done. Also the
* exact same dialog the FD Dashboard's Renew action opens (see fd-dashboard.component.ts's
* openRenewDialog), just without dashboardRenewal data, so its date picker stays visible.
*/
private openPaymentDialog(): void {
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
width: '640px',
width: '720px',
maxWidth: '95vw',
data: {
accountId: this.fixedDepositsAccountData.id,
@@ -293,6 +300,60 @@ export class FixedDepositAccountViewComponent implements OnInit {
});
}
/**
* The start date of the term currently running -- the last entry in renewalHistory, which is ordered
* oldest-first (see RenewalHistoryTabComponent). Falls back to the account's own activation date for
* an FD that has never been renewed (or whose renewalHistory hasn't loaded), which is the same date
* in that case anyway.
*/
private get currentTermStartDate(): any {
const history = this.fixedDepositsAccountData?.renewalHistory;
const latestTerm = Array.isArray(history) && history.length > 0 ? history[history.length - 1] : null;
return latestTerm?.activatedondate ?? this.fixedDepositsAccountData?.timeline?.activatedOnDate;
}
/**
* Opens the Premature Close action -- Active-only, no reinvest option, since the deposit hasn't
* matured yet.
*/
private openPrematureCloseDialog(): void {
const prematureCloseDialogRef = this.dialog.open(PrematureCloseDialogComponent, {
width: '720px',
maxWidth: '95vw',
data: {
accountId: this.fixedDepositsAccountData.id,
accountNo: this.fixedDepositsAccountData.fdAccountNumber || this.fixedDepositsAccountData.accountNo,
// calculatePrematureAmount only ever returns the final maturityAmount, not how it was reached --
// these come from the account's own data (already loaded for this page) so the dialog can show
// the breakdown (deposit, contracted rate, penalty, interest earned) behind that one number.
depositAmount: this.fixedDepositsAccountData.depositAmount,
nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate,
preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable,
preClosurePenalInterest: this.fixedDepositsAccountData.preClosurePenalInterest,
preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType,
// Gate which RBI-mandated waiver reasons the dialog offers -- these two are the product's own
// facility toggles (Deceased Settlement / No Penalty Nominee-Heir Closure), off by default, so
// a product that never enabled them shouldn't offer a reason implying it did.
deceasedSettlement: this.fixedDepositsAccountData.deceasedSettlement,
noPenaltyNomineeHeirClosure: this.fixedDepositsAccountData.noPenaltyNomineeHeirClosure,
// Tenure context (current term's start date, maturity date, current balance) so the dialog can
// show how far into THIS term the account is and how much is actually sitting in it today, not
// just the penalty-adjusted amount calculatePrematureAmount returns. Deliberately the current
// term's own start (see currentTermStartDate), not the account's original activation -- a
// renewed FD's interest/tenure runs from the renewal, and showing the original activation date
// here would make an account renewed days ago look like it has been running for months.
activatedOnDate: this.currentTermStartDate,
maturityDate: this.fixedDepositsAccountData.maturityDate,
currentBalance: this.fixedDepositsAccountData.summary?.accountBalance
}
});
prematureCloseDialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
this.reload();
}
});
}
/**
* Deletes Fixed Deposits Account.
*/
@@ -360,7 +421,16 @@ export class FixedDepositAccountViewComponent implements OnInit {
if (response.confirm) {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.fixedDepositsAccountData.id, 'postInterest', {})
.subscribe(() => {
.subscribe((commandResponse: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with
// rollbackTransaction: true), so the reload below shows no new interest posting.
if (commandResponse?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Interest posting has been sent for checker approval. No interest is posted until a checker approves it.'
});
}
this.reload();
});
}
@@ -57,11 +57,12 @@ export class FixedDepositsButtonsConfiguration {
];
break;
case 'Matured':
// 'Close' (the generic Fineract apply-maturity-instructions action) is deliberately left out
// here -- what happens to a matured FD is now handled either automatically by the nightly job
// (an account opened with an explicit instruction) or manually via the FD Dashboard's Renew
// action (an account left without one, backdated within grace / gap-interest after). Closing
// from here would go around both of those paths.
this.buttonsArray = [
{
name: 'Close',
icon: 'fa fa-arrow-right'
},
{
name: 'Calculate Interest',
icon: 'fa fa-table'
@@ -109,7 +110,18 @@ export class FixedDepositsButtonsConfiguration {
setOptions(status: string) {
switch (status) {
case 'Active':
this.optionArray = [
{
name: 'Post Interest'
},
{
name: 'Add Charge'
}
];
break;
case 'Matured':
// Payment only makes sense once the deposit has actually matured -- an Active FD that needs
// closing early already has Premature Close for that.
this.optionArray = [
{
name: 'Post Interest'
@@ -41,8 +41,8 @@
) {
<div class="rh-node">
<div class="rh-node__rail">
<span class="rh-node__dot" [class.rh-node__dot--active]="term.renewalstatus === 'ACTIVE'">
@if (term.renewalstatus === 'ACTIVE') {
<span class="rh-node__dot" [class.rh-node__dot--active]="isTermActive(term, last)">
@if (isTermActive(term, last)) {
<span class="rh-node__pulse"></span>
}
</span>
@@ -51,15 +51,15 @@
}
</div>
<div class="rh-card" [class.rh-card--active]="term.renewalstatus === 'ACTIVE'">
<div class="rh-card" [class.rh-card--active]="isTermActive(term, last)">
<div class="rh-card__head">
<div class="rh-card__title-group">
<h4 class="rh-card__title">
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
</h4>
<span class="rh-badge" [class.rh-badge--active]="term.renewalstatus === 'ACTIVE'">
<fa-icon [icon]="term.renewalstatus === 'ACTIVE' ? 'check-circle' : 'clock'"></fa-icon>
{{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }}
<span class="rh-badge" [class.rh-badge--active]="isTermActive(term, last)">
<fa-icon [icon]="isTermActive(term, last) ? 'check-circle' : 'clock'"></fa-icon>
{{ termStatusLabel(term, last) }}
</span>
@if (renewalTypeLabel(term)) {
<span class="rh-tag">
@@ -95,7 +95,7 @@
</div>
<div class="rh-stat">
<span class="rh-stat__label">{{
term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned'
isTermActive(term, last) ? 'Interest So Far' : 'Interest Earned'
}}</span>
<span class="rh-stat__value rh-stat__value--positive">
{{
@@ -106,9 +106,7 @@
</span>
</div>
<div class="rh-stat rh-stat--highlight">
<span class="rh-stat__label">{{
term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At'
}}</span>
<span class="rh-stat__label">{{ maturityLabel(term, last) }}</span>
<span class="rh-stat__value">
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
</span>
@@ -183,6 +183,51 @@ export class RenewalHistoryTabComponent implements OnInit {
return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot';
}
/**
* Whether one term's card should show as "still running". `term.renewalstatus` only ever flips from
* ACTIVE to MATURED when a term rolls into its successor (see FixedDepositRenewalHistory#markMatured
* on the backend) -- a premature closure never touches that row, so the latest term's archived status
* would read ACTIVE forever even after the whole account has closed. For the LAST term only, this
* cross-checks the account's own live status instead of trusting the archived flag; every earlier
* term is reliably matured already (something renewed past it), so its own renewalstatus is
* trustworthy as-is and doesn't need the live account status at all.
*/
isTermActive(term: any, isLast: boolean): boolean {
if (isLast && this.fixedDepositsAccountData?.status) {
return !!this.fixedDepositsAccountData.status.active;
}
return term?.renewalstatus === 'ACTIVE';
}
/** Badge text for one term's card -- see {@link isTermActive} for why the last term needs its own check. */
termStatusLabel(term: any, isLast: boolean): string {
if (this.isTermActive(term, isLast)) {
return 'Active';
}
if (isLast && this.fixedDepositsAccountData?.status?.prematureClosed) {
return 'Premature Closed';
}
return 'Matured';
}
/**
* Label for the maturity/value figure -- "Projected Maturity" only makes sense while the term is
* still genuinely running towards that projection. `term.maturedamount` (the archived settled value)
* is only ever written when a term rolls into its successor, same gap as {@link isTermActive}: a
* premature closure settles the account without ever touching that column, so the last term would
* keep reading as a live projection forever even though the figure showing is already the final,
* settled one (see {@link maturityFor}).
*/
maturityLabel(term: any, isLast: boolean): string {
if (term?.maturedamount != null) {
return 'Matured At';
}
if (isLast && !this.isTermActive(term, isLast)) {
return 'Closed At';
}
return 'Projected Maturity';
}
/**
* The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source.
*
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -98,17 +100,31 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
dateFormat,
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'approve', data).subscribe(() => {
const activationData = {
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'approve', data)
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true).
// The account is therefore still pending, so chaining activation onto it would be applied to an
// unapproved account -- stop here and say what is actually waiting.
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'This approval has been sent for checker approval. The account stays pending until a checker approves it.'
});
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
return;
}
const activationData = {
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
}
}
@@ -21,6 +21,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FixedDepositsService } from '../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -43,6 +44,7 @@ export class CloseFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -139,8 +141,18 @@ export class CloseFixedDepositsAccountComponent implements OnInit {
dateFormat,
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'close', data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'close', data)
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
// so the deposit reloads unchanged -- say so instead of letting that read as "nothing happened".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Closure has been sent for checker approval. The deposit is unchanged until a checker approves it.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
@@ -24,9 +24,6 @@
@if (actions['Add Charge']) {
<mifosx-add-charge-fixed-deposits-account></mifosx-add-charge-fixed-deposits-account>
}
@if (actions['Premature Close']) {
<mifosx-premature-close-fixed-deposits-account></mifosx-premature-close-fixed-deposits-account>
}
@if (actions['Close']) {
<mifosx-close-fixed-deposits-account></mifosx-close-fixed-deposits-account>
}
@@ -15,7 +15,6 @@ import { ActivateFixedDepositsAccountComponent } from './activate-fixed-deposits
import { UndoApprovalFixedDepositsAccountComponent } from './undo-approval-fixed-deposits-account/undo-approval-fixed-deposits-account.component';
import { WithdrawByClientFixedDepositsAccountComponent } from './withdraw-by-client-fixed-deposits-account/withdraw-by-client-fixed-deposits-account.component';
import { AddChargeFixedDepositsAccountComponent } from './add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component';
import { PrematureCloseFixedDepositsAccountComponent } from './premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component';
import { CloseFixedDepositsAccountComponent } from './close-fixed-deposits-account/close-fixed-deposits-account.component';
import { FixedDepositsCashTransactionComponent } from './fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -35,7 +34,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
UndoApprovalFixedDepositsAccountComponent,
WithdrawByClientFixedDepositsAccountComponent,
AddChargeFixedDepositsAccountComponent,
PrematureCloseFixedDepositsAccountComponent,
CloseFixedDepositsAccountComponent,
FixedDepositsCashTransactionComponent
]
@@ -52,7 +50,6 @@ export class FixedDepositsAccountActionsComponent {
'Undo Approval': boolean;
'Undo Activation': boolean;
'Add Charge': boolean;
'Premature Close': boolean;
'Withdrawn by Client': boolean;
Withdrawal: boolean;
Deposit: boolean;
@@ -64,7 +61,6 @@ export class FixedDepositsAccountActionsComponent {
'Undo Approval': false,
'Undo Activation': false,
'Add Charge': false,
'Premature Close': false,
'Withdrawn by Client': false,
Withdrawal: false,
Deposit: false
@@ -1,105 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card>
<form [formGroup]="prematureCloseAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Premature Close Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (prematureCloseAccountForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Premature Close Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (prematureCloseAccountForm.controls.maturityAmount) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Maturity Amount' | translate }}</mat-label>
<input type="number" matInput formControlName="maturityAmount" />
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('onAccountClosureId')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Action' | translate }}</mat-label>
<mat-select required formControlName="onAccountClosureId">
@for (closureOption of onAccountClosureOptions; track closureOption) {
<mat-option [value]="closureOption.id">
{{ closureOption.value }}
</mat-option>
}
</mat-select>
@if (prematureCloseAccountForm.controls.onAccountClosureId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('toSavingsAccountId')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
{{ account.accountNo }}
</mat-option>
}
</mat-select>
@if (prematureCloseAccountForm.controls.toSavingsAccountId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('transferDescription')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('note')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
}
</div>
</mat-card-content>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="warn" [disabled]="!prematureCloseAccountForm.valid">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
</div>
@@ -1,11 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
max-width: 37rem;
}
@@ -1,179 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import {
UntypedFormGroup,
UntypedFormBuilder,
Validators,
UntypedFormControl,
ReactiveFormsModule
} from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */
import { FixedDepositsService } from '../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Premature Close Fixed Deposits Account Component
*/
@Component({
selector: 'mifosx-premature-close-fixed-deposits-account',
templateUrl: './premature-close-fixed-deposits-account.component.html',
styleUrls: ['./premature-close-fixed-deposits-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CdkTextareaAutosize
]
})
export class PrematureCloseFixedDepositsAccountComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private dateUtils = inject(Dates);
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
maxDate = new Date();
/** Premature Close FD Account form. */
prematureCloseAccountForm: UntypedFormGroup;
/** Savings Account Data */
savingsAccountsData: any;
/** On account Closure Options */
onAccountClosureOptions: any;
/** Fixed Deposits Account Id */
accountId: any;
/** Form submission event */
isSubmitted = false;
/**
* @param {FormBuilder} formBuilder Form Builder
* @param {FixedDepositsService} fixedDepositsService Fixed Deposits Service
* @param {Dates} dateUtils Date Utils
* @param {ActivatedRoute} route Activated Route
* @param {Router} router Router
* @param {SettingsService} settingsService Settings Service
*/
constructor() {
this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId'];
}
/**
* Creates the premature close fd account form.
*/
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createPrematureCloseAccountForm();
this.buildDependencies();
}
/**
* Creates the premature close fd account form.
*/
createPrematureCloseAccountForm() {
this.prematureCloseAccountForm = this.formBuilder.group({
closedOnDate: [
'',
Validators.required
]
});
}
/**
* Subscribes to value changes of parent control.
*/
buildDependencies() {
this.prematureCloseAccountForm.get('closedOnDate').valueChanges.subscribe((value: Date) => {
if (!this.isSubmitted) {
this.calculatePrematureAmount(value);
}
});
}
/**
* Calculates prematue amount based on closed on date.
* @param {Date} date Premature Close Date
*/
calculatePrematureAmount(date: Date) {
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const data = {
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'calculatePrematureAmount', data)
.subscribe((response: any) => {
this.savingsAccountsData = response.savingsAccounts;
this.onAccountClosureOptions = response.onAccountClosureOptions;
this.prematureCloseAccountForm.addControl(
'maturityAmount',
new UntypedFormControl({ value: '', disabled: true })
);
this.prematureCloseAccountForm.addControl(
'onAccountClosureId',
new UntypedFormControl('', Validators.required)
);
this.prematureCloseAccountForm.addControl('note', new UntypedFormControl(''));
this.prematureCloseAccountForm.get('maturityAmount').patchValue(response.maturityAmount);
this.addTransferDetails();
});
}
/**
* Subscribes to value changes of `onAccountClosureId` adds and removes transfer details accordingly.
*/
addTransferDetails() {
this.prematureCloseAccountForm.get('onAccountClosureId').valueChanges.subscribe((id: any) => {
if (id === 200) {
this.prematureCloseAccountForm.addControl(
'toSavingsAccountId',
new UntypedFormControl('', Validators.required)
);
this.prematureCloseAccountForm.addControl('transferDescription', new UntypedFormControl(''));
} else {
this.prematureCloseAccountForm.removeControl('toSavingsAccountId');
this.prematureCloseAccountForm.removeControl('transferDescription');
}
});
}
/**
* Submits the form and premature closes the fd account,
* if successful redirects to the fd account.
*/
submit() {
const prematureCloseAccountFormData = this.prematureCloseAccountForm.value;
this.isSubmitted = true;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const prevClosedDate: Date = this.prematureCloseAccountForm.value.closedOnDate;
if (prematureCloseAccountFormData.closedOnDate instanceof Date) {
prematureCloseAccountFormData.closedOnDate = this.dateUtils.formatDate(prevClosedDate, dateFormat);
}
const data = {
...prematureCloseAccountFormData,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'prematureClose', data)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class RejectFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -97,8 +99,19 @@ export class RejectFixedDepositsAccountComponent implements OnInit {
dateFormat,
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'reject', data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'reject', data)
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
// so the application is still open -- say so instead of letting that read as "nothing happened".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Rejection has been sent for checker approval. The application is unchanged until a checker approves it.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
@@ -12,6 +12,7 @@ import { UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule } from '@angu
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */
import { AlertService } from 'app/core/alert/alert.service';
import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from '../../fixed-deposits.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
@@ -35,6 +36,7 @@ export class UndoApprovalFixedDepositsAccountComponent implements OnInit {
private fixedDepositsService = inject(FixedDepositsService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private alertService = inject(AlertService);
/** Undo Approval Fixed Deposits Account form. */
undoApprovalFixedDepositsAccountForm: UntypedFormGroup;
@@ -87,12 +89,30 @@ export class UndoApprovalFixedDepositsAccountComponent implements OnInit {
if (this.undoAction === 'Undo Activation') {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, this.undoCommand, data)
.subscribe(() => {
.subscribe((response: any) => {
this.notifyIfSentForApproval(response);
this.router.navigate(['../../'], { relativeTo: this.route });
});
} else {
this.savingsService.executeSavingsAccountCommand(this.accountId, this.undoCommand, data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
this.savingsService
.executeSavingsAccountCommand(this.accountId, this.undoCommand, data)
.subscribe((response: any) => {
this.notifyIfSentForApproval(response);
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
/**
* Both undo commands sit on maker-checker permissions, so either can come back parked in the
* Checker Inbox rather than executed -- HTTP 200 with rollbackTransaction: true, and the account
* reloaded unchanged. Without this the navigation away reads as "it worked".
*/
private notifyIfSentForApproval(response: any): void {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: `${this.undoAction} has been sent for checker approval. The account is unchanged until a checker approves it.`
});
}
}
@@ -11,6 +11,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class WithdrawByClientFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -99,7 +101,16 @@ export class WithdrawByClientFixedDepositsAccountComponent implements OnInit {
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'withdrawnByApplicant', data)
.subscribe(() => {
.subscribe((response: any) => {
// Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true),
// so the application is still open -- say so instead of letting that read as "nothing happened".
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message:
'Withdrawal has been sent for checker approval. The application is unchanged until a checker approves it.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
@@ -35,7 +35,6 @@ import { ViewTransactionComponent } from './fixed-deposit-account-view/view-tran
import { WaiveChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/waive-charge-dialog/waive-charge-dialog.component';
import { InactivateChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/inactivate-charge-dialog/inactivate-charge-dialog.component';
import { AddChargeFixedDepositsAccountComponent } from './fixed-deposits-account-actions/add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component';
import { PrematureCloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component';
import { CloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/close-fixed-deposits-account/close-fixed-deposits-account.component';
import { CreateFixedDepositAccountComponent } from './create-fixed-deposit-account/create-fixed-deposit-account.component';
import { FixedDepositAccountDetailsStepComponent } from './fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component';
@@ -78,7 +77,6 @@ import { FixedDepositsCashTransactionComponent } from './fixed-deposits-account-
WaiveChargeDialogComponent,
InactivateChargeDialogComponent,
AddChargeFixedDepositsAccountComponent,
PrematureCloseFixedDepositsAccountComponent,
CloseFixedDepositsAccountComponent,
CreateFixedDepositAccountComponent,
FixedDepositAccountDetailsStepComponent,
@@ -133,7 +133,8 @@ export class FixedDepositsService {
calculateFixedDepositInterest(params: {
principalAmount: number;
annualInterestRate: number;
tenureInMonths: number;
tenureInMonths?: number;
tenureInDays?: number; // alternative to tenureInMonths, for sub-month FD Single tenures
interestCompoundingPeriodInMonths: number;
interestPostingPeriodInMonths: number;
submittedOnDate?: string; // yyyy-MM-dd — enables exact calendar calculation
@@ -153,6 +154,33 @@ export class FixedDepositsService {
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
}
/**
* FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period
* configured: backdated to the original maturity date within the grace window, or from today
* with a gap-period interest credit (at the linked Savings account's rate) once it has passed.
* @param accountId Fixed Deposit Account Id
* @returns {Observable<any>} { renewedAccountId, maturityDate, maturityAmount, backdated, gapInterestAmount? }
*/
/** `reinvestType` is the staff's choice made at renewal time: 300 = Reinvest Principal + Interest,
* 400 = Reinvest Principal Only (rest of the balance goes to the FD's linked Savings account). */
renewFixedDepositAccount(accountId: string | number, reinvestType: 300 | 400): Observable<any> {
return this.http.post(`/fixeddepositaccounts/${accountId}/renew`, { reinvestType });
}
/** Pre-step the Payment dialog calls, from the FD Dashboard, before submitting the generic `payment`
* command -- credits gap-period interest (at the linked account's rate) if the product's renewal
* grace window has passed; a no-op (`gapInterestAmount: 0`) if still within it. */
creditGapInterestIfPastGrace(accountId: string | number): Observable<any> {
return this.http.post(`/fixeddepositaccounts/${accountId}/credit-gap-interest`, {});
}
/** Read-only -- what a Renew is about to do (current balance, linked account's rate, gap days,
* gap-interest amount, projected new balance), shown in the Payment dialog before anything is
* confirmed. Posts nothing; safe to call as often as the dialog needs. */
getFdRenewalPreview(accountId: string | number): Observable<any> {
return this.http.get(`/fixeddepositaccounts/${accountId}/renewal-preview`);
}
/**
* @param fixedDepositAccount Fixed Deposit Account
* @returns {Observable<any>}
@@ -195,6 +195,13 @@ export class CreateRecurringDepositsAccountComponent {
});
return;
}
if (this.recurringDepositsAccountProductTemplate?.introducerRequired && !detailsStep?.introducerId) {
this.alertService.alert({
type: 'Error',
message: 'This product requires an introducer. Please choose an existing customer as introducer before submitting.'
});
return;
}
if (!this.selectedTenureValue || this.selectedTenureFrequencyId === null) {
this.alertService.alert({
type: 'Error',
@@ -209,6 +216,39 @@ export class CreateRecurringDepositsAccountComponent {
});
return;
}
// Same limits the server enforces - checked here so the message names the numbers before anything is sent.
const minInstallment = Number(this.recurringDepositsAccountProductTemplate?.minDepositAmount) || 0;
const maxInstallment = Number(this.recurringDepositsAccountProductTemplate?.maxDepositAmount) || 0;
if (minInstallment > 0 && this.selectedDepositAmount < minInstallment) {
this.alertService.alert({
type: 'Error',
message: `Installment ${this.selectedDepositAmount} is below this product's minimum of ${minInstallment}.`
});
return;
}
if (maxInstallment > 0 && this.selectedDepositAmount > maxInstallment) {
this.alertService.alert({
type: 'Error',
message: `Installment ${this.selectedDepositAmount} is above this product's maximum of ${maxInstallment}.`
});
return;
}
if (
this.recurringDepositsAccountCurrencyStep?.autoDebitFacility &&
detailsStep?.selectedLinkedAccount?.accountType !== 'SAVINGS'
) {
this.alertService.alert({
type: 'Error',
message:
'Auto Debit collects each installment from the linked account. Link a Savings account under Deposit Account Link, or turn Auto Debit off.'
});
return;
}
const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError;
if (lienError) {
this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` });
return;
}
if (!detailsStep?.selectedCustomerSegment) {
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
return;
@@ -255,8 +295,16 @@ export class CreateRecurringDepositsAccountComponent {
isCalendarInherited: pt?.isCalendarInherited ?? false,
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
allowWithdrawal: pt?.allowWithdrawal ?? false,
adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false
// Money paid ahead of a due date counts towards the next installments. Without this an advance cash
// payment stayed unallocated: the schedule kept showing the installment unpaid, so Auto Debit collected it
// a second time and the missed-installment penalty charged it. The RD product form has no such option, so
// the product's value was always false.
adjustAdvanceTowardsFuturePayments: true,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false,
// Lien Marked products only - the server ignores it otherwise and caps it at the product's limit.
...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null
? { lienPercentage: Number(currencyStep.lienPercentage) }
: {})
};
// Charges from product template
@@ -233,6 +233,11 @@ export class EditRecurringDepositAccountComponent {
});
return;
}
const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError;
if (lienError) {
this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` });
return;
}
if (!detailsStep?.selectedCustomerSegment) {
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
return;
@@ -275,8 +280,13 @@ export class EditRecurringDepositAccountComponent {
isCalendarInherited: pt?.isCalendarInherited ?? false,
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
allowWithdrawal: pt?.allowWithdrawal ?? false,
adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false
// See create-recurring-deposits-account: advance payments must count towards the next installments.
adjustAdvanceTowardsFuturePayments: true,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false,
// Lien Marked products only - the server ignores it otherwise and caps it at the product's limit.
...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null
? { lienPercentage: Number(currencyStep.lienPercentage) }
: {})
};
const rawCharges: any[] = pt?.charges ?? [];
@@ -70,8 +70,9 @@ export class ActivateRecurringDepositsAccountComponent implements OnInit {
*/
createActivateRecurringDepositsAccountForm() {
this.activateRecurringDepositsAccountForm = this.formBuilder.group({
// Defaults to the business date (the branch's working day), which is what the account is normally activated on.
activatedOnDate: [
'',
this.settingsService.businessDate,
Validators.required
]
});
@@ -72,8 +72,9 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit {
*/
createApproveRecurringDepositsAccountForm() {
this.approveRecurringDepositsAccountForm = this.formBuilder.group({
// Defaults to the business date (the branch's working day), which is what the account is normally approved on.
approvedOnDate: [
'',
this.settingsService.businessDate,
Validators.required
],
note: ['']
@@ -13,6 +13,16 @@
<div class="layout-column">
<h3 class="mat-h3 flex-fill">{{ title }}</h3>
@if (totalLienAmount > 0) {
<p class="lien-warn">
This deposit is pledged as loan security (lien {{ totalLienAmount | number: '1.2-2' }} -
@for (lien of liens; track lien.loanId; let last = $last) {
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
). It cannot be closed until the loan is repaid or the guarantee is released.
</p>
}
<mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
<input
@@ -154,7 +164,7 @@
<button type="button" mat-raised-button [routerLink]="['../general']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid">
<button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid || totalLienAmount > 0">
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
@@ -10,6 +10,12 @@
max-width: 37rem;
}
.lien-warn {
margin: 0 0 16px;
font-size: 13px;
color: #c62828;
}
.expandcollapsebutton {
margin-top: -7px;
margin-left: 2%;
@@ -64,6 +64,9 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
maxDate = new Date();
/** Close Recurring Deposit Account form. */
closeRecurringDepositForm: UntypedFormGroup;
/** Loans this deposit secures. While any remain, the server refuses to close it. */
liens: any[] = [];
totalLienAmount = 0;
/**
* Retrieves action details template data from `resolve`
@@ -79,6 +82,11 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
this.maturityAmount = data.recurringDepositsAccountActionData.maturityAmount;
this.onAccountClosureOptions = data.recurringDepositsAccountActionData.onAccountClosureOptions;
this.paymentTypes = data.recurringDepositsAccountActionData.paymentTypeOptions;
// A matured deposit closes on or after its maturity date - the server refuses anything earlier.
const maturityDate = data.recurringDepositsAccountActionData.maturityDate;
if (Array.isArray(maturityDate) && maturityDate.length >= 3) {
this.minDate = new Date(maturityDate[0], maturityDate[1] - 1, maturityDate[2]);
}
if (data.recurringDepositsAccountActionData.maturityAmount) {
this.title = 'Recurring Deposit Closure on Maturity';
} else {
@@ -92,6 +100,10 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
this.maxDate = this.settingsService.businessDate;
this.createcloseRecurringDepositForm();
this.addTransferDetails();
this.recurringDepositsService.getRecurringDepositLiens(this.accountId).subscribe((response: any) => {
this.liens = response?.liens ?? [];
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
});
}
/**
@@ -99,8 +111,10 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
*/
createcloseRecurringDepositForm() {
this.closeRecurringDepositForm = this.formBuilder.group({
// The business date, not the computer's clock: the branch's working day can differ from the calendar day,
// and closing on the clock's date (before maturity, or before the deposit's last installment) fails.
closedOnDate: [
new Date(),
this.settingsService.businessDate,
Validators.required
],
maturityAmount: [{ value: this.maturityAmount, disabled: true }],
@@ -11,6 +11,51 @@
<form [formGroup]="prematureCloseRecurringDepositsAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
@if (account) {
<div class="pc-context">
<div class="pc-row">
<span>Account</span><b>{{ account.accountNo }}</b>
</div>
<div class="pc-row">
<span>Current balance</span><b>{{ currencySymbol }} {{ balance | number: '1.2-2' }}</b>
</div>
@if (account.maturityDate) {
<div class="pc-row">
<span>Maturity date</span><b>{{ account.maturityDate | dateFormat }}</b>
</div>
}
@if (account.maturityAmount != null) {
<div class="pc-row">
<span>Amount at maturity</span><b>{{ currencySymbol }} {{ account.maturityAmount | number: '1.2-2' }}</b>
</div>
}
<div class="pc-row">
<span>Pre-closure penalty</span>
<b>
@if (account.preClosurePenalApplicable) {
{{ account.preClosurePenalInterest }}% less interest
@if (account.preClosurePenalInterestOnType?.value) {
({{ account.preClosurePenalInterestOnType.value }})
}
} @else {
None
}
</b>
</div>
</div>
}
@if (totalLienAmount > 0) {
<p class="pc-warn">
This deposit is pledged as loan security (lien {{ currencySymbol }}
{{ totalLienAmount | number: '1.2-2' }} -
@for (lien of liens; track lien.loanId; let last = $last) {
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
). It cannot be closed until the loan is repaid or the guarantee is released.
</p>
}
<mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
<input
@@ -23,6 +68,7 @@
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
<mat-hint>Must be before the maturity date - on or after it, use Close instead.</mat-hint>
@if (prematureCloseRecurringDepositsAccountForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
@@ -30,6 +76,89 @@
</mat-error>
}
</mat-form-field>
@if (reasonOptions.length > 1) {
<mat-form-field subscriptSizing="dynamic">
<mat-label>Closure reason</mat-label>
<mat-select formControlName="prematureClosureReasonId">
@for (reason of reasonOptions; track reason.id) {
<mat-option [value]="reason.id">{{ reason.label }}</mat-option>
}
</mat-select>
<mat-hint>{{ selectedReasonHint }}</mat-hint>
</mat-form-field>
}
<div class="pc-calc">
<button
type="button"
mat-stroked-button
color="primary"
(click)="calculate()"
[disabled]="
calculating || totalLienAmount > 0 || !prematureCloseRecurringDepositsAccountForm.value.closedOnDate
"
>
{{ calculating ? 'Calculating…' : 'Calculate Payout' }}
</button>
</div>
@if (preview) {
<div class="pc-result">
<div class="pc-row pc-row--total">
<span>Payable on premature closure</span>
<b>{{ currencySymbol }} {{ preview.payoutAmount | number: '1.2-2' }}</b>
</div>
@if (shortfallVsMaturity != null && shortfallVsMaturity > 0) {
<div class="pc-row pc-row--muted">
<span>Less than at maturity by</span>
<b>{{ currencySymbol }} {{ shortfallVsMaturity | number: '1.2-2' }}</b>
</div>
}
<p class="pc-note">
Paid by transfer to the client's savings account - a recurring deposit cannot be broken for cash.
</p>
</div>
@if (destinationAccounts.length === 0) {
<p class="pc-warn">
This client has no active savings account to receive the proceeds. Open one first, then close this
deposit.
</p>
} @else {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (savings of destinationAccounts; track savings.id) {
<mat-option [value]="savings.id">
{{ savings.accountNo }} - {{ savings.productName }}
@if (savings.owner) {
(nominee: {{ savings.owner }})
} @else if (savings.id === account?.linkedAccountId) {
(linked)
}
</mat-option>
}
</mat-select>
@if (prematureCloseRecurringDepositsAccountForm.controls.toSavingsAccountId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput cdkTextareaAutosize formControlName="note"></textarea>
</mat-form-field>
}
}
</div>
</mat-card-content>
@@ -37,8 +166,8 @@
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!prematureCloseRecurringDepositsAccountForm">
{{ 'labels.buttons.Confirm' | translate }}
<button mat-raised-button color="primary" [disabled]="!canConfirm">
{{ submitting ? 'Closing…' : ('labels.buttons.Confirm' | translate) }}
</button>
</mat-card-actions>
</form>
@@ -9,3 +9,45 @@
.container {
max-width: 37rem;
}
.pc-context,
.pc-result {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
margin-bottom: 16px;
border: 1px solid rgb(128 128 128 / 30%);
border-radius: 6px;
}
.pc-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13px;
&--total {
font-size: 15px;
}
&--muted {
opacity: 0.75;
}
}
.pc-calc {
margin: 4px 0 16px;
}
.pc-note {
margin: 4px 0 0;
font-size: 12px;
opacity: 0.75;
}
.pc-warn {
margin: 0 0 16px;
font-size: 13px;
color: #c62828;
}
@@ -8,25 +8,41 @@
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
/** Custom Services */
import { RecurringDepositsService } from '../../recurring-deposits.service';
import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** DepositAccountOnClosureType.TRANSFER_TO_SAVINGS - the only way a broken RD may be paid out. */
const TRANSFER_TO_SAVINGS = 200;
/** PrematureClosureReasonType values the RD closure understands. */
const REASON_NORMAL = 100;
const REASON_DECEASED = 400;
const REASON_NOMINEE_HEIR = 500;
/**
* Premature Close Recurring Deposits Account Component
*
* The server will only break a recurring deposit early by transferring the proceeds to one of the client's savings
* accounts (no cash payout, no reinvest), and it needs to be told so: onAccountClosureId and toSavingsAccountId are
* mandatory. This screen used to send only the closure date, so every premature closure was rejected. It now asks the
* server what the deposit would pay on the chosen date (calculatePrematureAmount, which applies the product's
* pre-closure penalty) and only lets the closure be confirmed against that exact figure.
*/
@Component({
selector: 'mifosx-premature-close-recurring-deposit-account',
templateUrl: './premature-close-recurring-deposit-account.component.html',
styleUrls: ['./premature-close-recurring-deposit-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
CdkTextareaAutosize
]
})
export class PrematureCloseRecurringDepositAccountComponent implements OnInit {
@@ -36,72 +52,263 @@ export class PrematureCloseRecurringDepositAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private clientsService = inject(ClientsService);
/** Minimum date allowed. */
/** Savings accounts of this deposit's nominees - offered as destinations only for a deceased holder. */
nomineeSavingsAccounts: any[] = [];
/** Minimum date allowed: the deposit's activation date once loaded. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
/** Maximum date allowed: the business date, and never the maturity date or later (that is a normal Close). */
maxDate = new Date();
/** premature close Recurring Deposits Account form. */
prematureCloseRecurringDepositsAccountForm: UntypedFormGroup;
/** Recurring Deposits Account Id */
accountId: any;
/** The deposit being closed, for the context panel and the default destination account. */
account: any;
/** Result of the last calculatePrematureAmount call, and the date it was calculated for. */
preview: { payoutAmount: number; savingsAccounts: any[]; closureOptionId: number } | null = null;
previewedFor: string | null = null;
calculating = false;
submitting = false;
/** Loans this deposit secures. While any remain, the server refuses to close it. */
liens: any[] = [];
totalLienAmount = 0;
/**
* @param {FormBuilder} formBuilder Form Builder
* @param {RecurringDepositsService} recurringDepositsService Recurring Deposits Service
* @param {Dates} dateUtils Date Utils
* @param {ActivatedRoute} route Activated Route
* @param {Router} router Router
* @param {SettingsService} settingsService Settings Service
*/
constructor() {
this.accountId = this.route.parent.snapshot.params['recurringDepositAccountId'];
}
/**
* Creates the premature close recurring deposits form.
*/
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createprematureCloseRecurringDepositsAccountForm();
}
/**
* Creates the premature close recurring deposits account form.
*/
createprematureCloseRecurringDepositsAccountForm() {
this.prematureCloseRecurringDepositsAccountForm = this.formBuilder.group({
closedOnDate: [
'',
Validators.required
]
this.recurringDepositsService.getRecurringDepositsAccountData(this.accountId).subscribe((account: any) => {
this.account = account;
const activatedOn = this.toDate(account?.timeline?.activatedOnDate);
if (activatedOn) {
this.minDate = activatedOn;
}
const maturity = this.toDate(account?.maturityDate);
if (maturity) {
const dayBeforeMaturity = new Date(maturity);
dayBeforeMaturity.setDate(dayBeforeMaturity.getDate() - 1);
if (dayBeforeMaturity < this.maxDate) {
this.maxDate = dayBeforeMaturity;
}
}
});
this.recurringDepositsService.getRecurringDepositLiens(this.accountId).subscribe((response: any) => {
this.liens = response?.liens ?? [];
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
});
// A different date or reason is a different payout - the old preview no longer describes what would happen.
this.prematureCloseRecurringDepositsAccountForm.get('closedOnDate').valueChanges.subscribe(() => {
this.preview = null;
this.previewedFor = null;
});
this.prematureCloseRecurringDepositsAccountForm.get('prematureClosureReasonId').valueChanges.subscribe((reason: number) => {
this.preview = null;
this.previewedFor = null;
if (reason === REASON_DECEASED) {
this.loadNomineeSavingsAccounts();
}
});
}
/**
* Submits the form and premature closes the recurring deposit account,
* if successful redirects to the recurring deposit account.
*/
submit() {
const prematureCloseRecurringDepositsAccountFormData = this.prematureCloseRecurringDepositsAccountForm.value;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const prevClosedOnDate: Date = this.prematureCloseRecurringDepositsAccountForm.value.closedOnDate;
if (prematureCloseRecurringDepositsAccountFormData.closedOnDate instanceof Date) {
prematureCloseRecurringDepositsAccountFormData.closedOnDate = this.dateUtils.formatDate(
prevClosedOnDate,
dateFormat
);
/** Reasons this deposit's product allows; Normal is always there. */
get reasonOptions(): Array<{ id: number; label: string; hint: string }> {
const options = [{ id: REASON_NORMAL, label: 'Normal', hint: "Pre-closure penalty as per the deposit's terms." }];
if (this.account?.noPenaltyNomineeHeirClosure) {
options.push({ id: REASON_NOMINEE_HEIR, label: 'Nominee / legal heir claim', hint: 'No pre-closure penalty.' });
}
const data = {
...prematureCloseRecurringDepositsAccountFormData,
dateFormat,
locale
};
this.recurringDepositsService
.executeRecurringDepositsAccountCommand(this.accountId, 'prematureClose', data)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
if (this.account?.deceasedSettlement) {
options.push({
id: REASON_DECEASED,
label: 'Account holder deceased',
hint: "No penalty, allowed inside lock-in; may be paid to a nominee's savings account."
});
}
return options;
}
get selectedReasonHint(): string {
const reason = this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId;
return this.reasonOptions.find((option) => option.id === reason)?.hint ?? '';
}
/** The client's own savings accounts, plus - for a deceased holder - the nominees' savings accounts. */
get destinationAccounts(): any[] {
const own = (this.preview?.savingsAccounts ?? []).map((acc: any) => ({ ...acc, owner: null }));
if (this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId !== REASON_DECEASED) {
return own;
}
const ownIds = new Set(own.map((acc: any) => acc.id));
return [
...own,
...this.nomineeSavingsAccounts.filter((acc: any) => !ownIds.has(acc.id))
];
}
private loadNomineeSavingsAccounts() {
this.nomineeSavingsAccounts = [];
const clientId = this.account?.clientId;
if (!clientId || !this.account?.accountNo) {
return;
}
this.clientsService.getClientNominees(clientId, this.account.accountNo, 'RD').subscribe((nominees: any) => {
const list: any[] = Array.isArray(nominees) ? nominees : (nominees?.pageItems ?? []);
list
.filter((nominee: any) => nominee?.nomineeClientId && nominee.isActive !== false)
.forEach((nominee: any) => {
this.clientsService.getClientAccountData(nominee.nomineeClientId).subscribe((accounts: any) => {
const savings = (accounts?.savingsAccounts ?? []).filter(
(acc: any) => acc?.depositType?.id === 100 && acc?.status?.active
);
this.nomineeSavingsAccounts = [
...this.nomineeSavingsAccounts,
...savings.map((acc: any) => ({
id: acc.id,
accountNo: acc.accountNo,
productName: acc.productName,
owner: nominee.nomineeDisplayName
}))
];
});
});
});
}
createprematureCloseRecurringDepositsAccountForm() {
this.prematureCloseRecurringDepositsAccountForm = this.formBuilder.group({
closedOnDate: [
this.settingsService.businessDate,
Validators.required
],
prematureClosureReasonId: [REASON_NORMAL],
toSavingsAccountId: [
null,
Validators.required
],
transferDescription: ['Premature closure of recurring deposit'],
note: ['']
});
}
get balance(): number | null {
return this.account?.summary?.accountBalance ?? null;
}
get currencySymbol(): string {
return this.account?.currency?.displaySymbol || this.account?.currency?.code || '';
}
/** What breaking early costs compared with letting the deposit run to maturity, when both are known. */
get shortfallVsMaturity(): number | null {
const maturityAmount = this.account?.maturityAmount;
if (!this.preview || maturityAmount == null) {
return null;
}
return Number(maturityAmount) - Number(this.preview.payoutAmount);
}
get canConfirm(): boolean {
return (
this.totalLienAmount <= 0 &&
!!this.preview &&
this.previewedFor === this.formattedClosedOnDate &&
this.prematureCloseRecurringDepositsAccountForm.valid &&
!this.submitting
);
}
private get formattedClosedOnDate(): string | null {
const value = this.prematureCloseRecurringDepositsAccountForm.value.closedOnDate;
if (!value) {
return null;
}
return value instanceof Date ? this.dateUtils.formatDate(value, this.settingsService.dateFormat) : value;
}
/** Asks the server what the deposit pays if broken on the chosen date. */
calculate() {
const closedOnDate = this.formattedClosedOnDate;
if (!closedOnDate) {
return;
}
this.calculating = true;
const data = {
closedOnDate,
prematureClosureReasonId: this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId,
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
this.recurringDepositsService.executeRecurringDepositsAccountCommand(this.accountId, 'calculatePrematureAmount', data).subscribe({
next: (response: any) => {
const savingsAccounts = response?.savingsAccounts ?? [];
const closureOptionId =
(response?.onAccountClosureOptions ?? []).find((option: any) => option.id === TRANSFER_TO_SAVINGS)?.id ??
TRANSFER_TO_SAVINGS;
this.preview = { payoutAmount: response?.maturityAmount, savingsAccounts, closureOptionId };
this.previewedFor = closedOnDate;
this.calculating = false;
this.selectDefaultDestination(savingsAccounts);
},
error: () => {
this.calculating = false;
}
});
}
/** The deposit's own linked savings account, when it has one the client can still receive into. */
private selectDefaultDestination(savingsAccounts: any[]) {
const control = this.prematureCloseRecurringDepositsAccountForm.get('toSavingsAccountId');
if (control.value && savingsAccounts.some((acc: any) => acc.id === control.value)) {
return;
}
const linkedId = this.account?.linkedAccountId;
const linked = savingsAccounts.find((acc: any) => acc.id === linkedId);
control.setValue(linked?.id ?? (savingsAccounts.length === 1 ? savingsAccounts[0].id : null));
}
submit() {
if (!this.canConfirm) {
return;
}
const formValue = this.prematureCloseRecurringDepositsAccountForm.value;
const data: any = {
closedOnDate: this.formattedClosedOnDate,
onAccountClosureId: this.preview.closureOptionId,
prematureClosureReasonId: formValue.prematureClosureReasonId,
toSavingsAccountId: formValue.toSavingsAccountId,
transferDescription: formValue.transferDescription,
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
if (formValue.note) {
data.note = formValue.note;
}
this.submitting = true;
this.recurringDepositsService.executeRecurringDepositsAccountCommand(this.accountId, 'prematureClose', data).subscribe({
next: () => {
this.router.navigate(['../../'], { relativeTo: this.route });
},
error: () => {
this.submitting = false;
}
});
}
/** API dates arrive as [yyyy, m, d] arrays or ISO strings. */
private toDate(value: any): Date | null {
if (!value) {
return null;
}
if (Array.isArray(value)) {
return new Date(value[0], value[1] - 1, value[2]);
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
}
@@ -30,6 +30,14 @@
@if (actions['Close']) {
<mifosx-close-recurring-deposits-account></mifosx-close-recurring-deposits-account>
}
@if (actions['Deposit'] || actions['Withdrawal']) {
<!-- A direct deposit from the account screen skips the manager's approval; RD installments come in through the
cashier's Receipt or Auto Debit instead, so the Deposit action is not offered here even by URL. -->
@if (actions['Deposit']) {
<p>
RD installments cannot be deposited from the account screen. Use a cashier Receipt, or Auto Debit from the linked
savings account.
</p>
}
@if (actions['Withdrawal']) {
<mifosx-deposit-recurring-deposits-account [currency]="currency"></mifosx-deposit-recurring-deposits-account>
}
@@ -30,11 +30,50 @@
<div class="racc-alert racc-alert--editable">
<div class="racc-alert__content">
<h4>Auto Debit Facility</h4>
<p>Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.</p>
@if (recurringDepositsAccountProductTemplate?.autoDebitFacility) {
<p>
Collect each installment automatically on its due date from the linked Savings account (Deposit Account
Link, Details tab). A short balance leaves it overdue and it is retried on the next run.
</p>
} @else {
<p>This product does not offer Auto Debit.</p>
}
</div>
<mat-slide-toggle [(ngModel)]="autoDebitFacility" color="primary"></mat-slide-toggle>
<mat-slide-toggle
[(ngModel)]="autoDebitFacility"
[disabled]="!recurringDepositsAccountProductTemplate?.autoDebitFacility"
color="primary"
></mat-slide-toggle>
</div>
@if (lienAllowed) {
<div class="racc-alert racc-alert--editable">
<div class="racc-alert__content">
<h4>Loan against this RD (Lien)</h4>
<p>
How much of the RD balance can be given as loan, checked on the balance at the time the loan is taken.
Product limit: {{ productLienLimit }}%.
</p>
@if (lienPercentageError) {
<p class="racc-lien-error">{{ lienPercentageError }}</p>
}
</div>
<mat-form-field appearance="outline" class="racc-lien-field" subscriptSizing="dynamic">
<mat-label>Max loan (%)</mat-label>
<input
matInput
type="number"
min="1"
[max]="productLienLimit"
step="0.01"
[(ngModel)]="lienPercentage"
[ngModelOptions]="{ standalone: true }"
/>
<span matTextSuffix>%</span>
</mat-form-field>
</div>
}
<div class="racc-alert">
<div class="racc-alert__content">
<h4>Dormancy Alert</h4>
@@ -126,6 +126,15 @@ $gray-900: #202124;
}
}
.racc-lien-field {
width: 140px;
flex-shrink: 0;
}
.racc-lien-error {
color: #c62828;
}
.racc-alert--editable {
border-color: #bae6fd;
background: #f0f9ff;
@@ -6,8 +6,9 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core';
import { Component, Input, OnChanges, SimpleChanges, ViewChild, inject } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RecurringDepositsService } from '../../recurring-deposits.service';
import { NomineeFormComponent } from 'app/shared/nominee-form/nominee-form.component';
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
import { MatSlideToggle } from '@angular/material/slide-toggle';
@@ -36,7 +37,14 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
showNomineeForm = false;
nomineeError = '';
private recurringDepositsService = inject(RecurringDepositsService);
autoDebitFacility = false;
/**
* Lien Marked products only: how much of this RD may be given as loan, as % of its balance when the loan is taken.
* Starts at the product's percentage; may be lowered, never raised above it.
*/
lienPercentage: number | null = null;
private existingAccountApplied = false;
@@ -50,13 +58,44 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
if (Array.isArray(ea.nominees) && ea.nominees.length > 0) {
this.nominees = [...ea.nominees];
}
// The account response does not carry the deposit's own lien percentage; the lien endpoint does.
if (ea.id) {
this.recurringDepositsService.getRecurringDepositLiens(ea.id).subscribe((response: any) => {
this.lienPercentage = response?.lienPercentage ?? response?.productLienPercentage ?? null;
});
}
}
if (changes['recurringDepositsAccountProductTemplate']?.currentValue && !this.existingAccountApplied) {
const pt = changes['recurringDepositsAccountProductTemplate'].currentValue;
this.autoDebitFacility = !!pt.autoDebitFacility;
this.lienPercentage = pt.lienMarked ? (pt.lienPercentage ?? 100) : null;
}
}
get lienAllowed(): boolean {
return !!this.recurringDepositsAccountProductTemplate?.lienMarked;
}
/** The product's ceiling; a product with no percentage allows the whole balance. */
get productLienLimit(): number {
return Number(this.recurringDepositsAccountProductTemplate?.lienPercentage) || 100;
}
/** Why the entered percentage would be refused, or null when it is fine. */
get lienPercentageError(): string | null {
if (!this.lienAllowed) {
return null;
}
if (this.lienPercentage === null || this.lienPercentage === undefined || `${this.lienPercentage}` === '') {
return 'Enter how much of the RD balance can be given as loan.';
}
const value = Number(this.lienPercentage);
if (!(value > 0) || value > this.productLienLimit) {
return `Must be more than 0% and at most ${this.productLienLimit}% (product limit).`;
}
return null;
}
get totalShare(): number {
return this.nominees.reduce((sum, n) => sum + (+n.percentageShare || 0), 0);
}
@@ -112,7 +151,8 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
get currencyStepData() {
return {
autoDebitFacility: this.autoDebitFacility
autoDebitFacility: this.autoDebitFacility,
...(this.lienAllowed && this.lienPercentage != null ? { lienPercentage: Number(this.lienPercentage) } : {})
};
}
}
@@ -121,11 +121,30 @@
{{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }}
</span>
</div>
<div class="rad-field rad-info-row">
<span class="rad-info-label">Introducer Required</span>
<span class="rad-badge" [class.rad-badge--yes]="productTemplate.introducerRequired">
{{ productTemplate.introducerRequired ? 'Yes' : 'No' }}
</span>
</div>
</div>
</div>
<!-- The product requires an introducer: an existing customer, never the depositor themselves. -->
@if (productTemplate?.introducerRequired) {
<mifosx-introducer-search
[excludedClientIds]="primaryClientId ? [primaryClientId] : []"
[preselectedIntroducer]="
introducerId ? { clientId: introducerId, displayName: introducerName, cifNumber: introducerCif } : null
"
(introducerChange)="onIntroducerChange($event)"
></mifosx-introducer-search>
}
<!-- ══ Deposit Account Link ══════════════════════════════════════════ -->
@if (productTemplate?.shareLinkingEnabled) {
<!-- Also offered when the product allows Auto Debit: that is the account installments are collected from. -->
@if (productTemplate?.shareLinkingEnabled || productTemplate?.autoDebitFacility) {
<div class="dal-card">
<div class="dal-card__header">
<div class="dal-card__header-left">

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