Compare commits

...
Author SHA1 Message Date
niteshandClaude Sonnet 5 244b70f926 fix(savings): show a clear message when an action is parked for checker approval
Approve, close, reject, undo-approval, withdraw-by-client, and post-interest
on a savings account all silently behaved as if nothing happened when the
backend parks the command in the Checker Inbox instead of executing it
(HTTP 200 with rollbackTransaction: true) -- misleading the maker into
thinking the action either failed or quietly succeeded. Each now shows an
explicit alert naming what was sent for approval and that the account/
application is unchanged until a checker approves it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 16:14:24 +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
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
214 changed files with 16984 additions and 1508 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
"ng": "ng", "ng": "ng",
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none", "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/", "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: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", "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", "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"> <div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label> <label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> From Date</label>
<mat-form-field appearance="outline" class="gl-ffield"> <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-toggle matSuffix [for]="dpFrom"></mat-datepicker-toggle>
<mat-datepicker #dpFrom></mat-datepicker> <mat-datepicker #dpFrom></mat-datepicker>
</mat-form-field> </mat-form-field>
@@ -288,7 +288,7 @@
<div class="gl-fgroup"> <div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label> <label class="gl-flabel"><fa-icon icon="calendar-alt"></fa-icon> To Date</label>
<mat-form-field appearance="outline" class="gl-ffield"> <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-toggle matSuffix [for]="dpTo"></mat-datepicker-toggle>
<mat-datepicker #dpTo></mat-datepicker> <mat-datepicker #dpTo></mat-datepicker>
</mat-form-field> </mat-form-field>
@@ -357,7 +357,6 @@
matInput matInput
[matDatepicker]="dpSubFrom" [matDatepicker]="dpSubFrom"
[min]="minDate" [min]="minDate"
[max]="maxDate"
[formControl]="submittedOnDateFrom" [formControl]="submittedOnDateFrom"
/> />
<mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle> <mat-datepicker-toggle matSuffix [for]="dpSubFrom"></mat-datepicker-toggle>
@@ -368,7 +367,7 @@
<div class="gl-fgroup"> <div class="gl-fgroup">
<label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label> <label class="gl-flabel"><fa-icon icon="calendar-check"></fa-icon> Submitted To</label>
<mat-form-field appearance="outline" class="gl-ffield"> <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-toggle matSuffix [for]="dpSubTo"></mat-datepicker-toggle>
<mat-datepicker #dpSubTo></mat-datepicker> <mat-datepicker #dpSubTo></mat-datepicker>
</mat-form-field> </mat-form-field>
@@ -539,7 +538,7 @@
</div> </div>
<div class="gl-table-shell"> <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 --> <!-- #ID -->
<ng-container matColumnDef="id"> <ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef mat-sort-header class="gl-th">#</mat-header-cell> <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 ────────────────────────────────────────────────────────────────── // ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1); minDate = new Date(2000, 0, 1);
maxDate = new Date(); maxDate: Date = null;
/** Accounting features switched off for this installation — see disabled-features.ts. */ /** Accounting features switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES; disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
@@ -208,7 +208,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
} }
ngOnInit() { ngOnInit() {
this.maxDate = this.settingsService.businessDate; this.maxDate = null;
this.paginator.pageSize = 10; this.paginator.pageSize = 10;
this.restoreState(); this.restoreState();
this.setFilteredOffices(); this.setFilteredOffices();
+11
View File
@@ -74,6 +74,17 @@ export class AccountsService {
return this.http.get('/fixeddepositaccounts', { params: httpParams }); 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');
}
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> { getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
let httpParams = new HttpParams() let httpParams = new HttpParams()
.set('paged', 'true') .set('paged', 'true')
+2
View File
@@ -63,6 +63,7 @@ import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
import { BankCalendarModule } from './bank-calendar/bank-calendar.module'; import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module'; import { DeafModule } from './deaf/deaf.module';
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module'; import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
import { DicgcModule } from './dicgc/dicgc.module'; import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module'; import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module'; import { GlReportsModule } from './gl-reports/gl-reports.module';
@@ -176,6 +177,7 @@ export function HttpLoaderFactory(http: HttpClient) {
BankCalendarModule, BankCalendarModule,
DeafModule, DeafModule,
SavingsDailyInterestModule, SavingsDailyInterestModule,
FdDashboardModule,
DicgcModule, DicgcModule,
FiuModule, FiuModule,
GlReportsModule, GlReportsModule,
@@ -17,7 +17,12 @@
</p> </p>
</div> </div>
</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> <fa-icon icon="file-excel"></fa-icon>
{{ exporting ? 'Exporting…' : 'Export' }} {{ exporting ? 'Exporting…' : 'Export' }}
</button> </button>
@@ -132,7 +137,9 @@
<td class="num">{{ r.approvedShares | number }}</td> <td class="num">{{ r.approvedShares | number }}</td>
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td> <td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
<td class="num">{{ r.trancheCount }}</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> <td class="mono">{{ r.activatedDate || '—' }}</td>
</tr> </tr>
} @empty { } @empty {
@@ -107,11 +107,26 @@
</span> </span>
</div> </div>
@if (canOperate) { @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 <mat-icon>lock</mat-icon> Close Day
</button> </button>
} }
</div> </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) { } @else if (loaded && canOperate) {
<div class="bd-current bd-current--shut"> <div class="bd-current bd-current--shut">
<div class="bd-current__main"> <div class="bd-current__main">
@@ -372,3 +372,30 @@
color: #d97706; 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); 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 { load(): void {
this.loading = true; this.loading = true;
this.errorMsg = ''; this.errorMsg = '';
@@ -210,6 +227,7 @@ export class BranchDayComponent implements OnInit {
this.canOperate = !!res?.canOperate; this.canOperate = !!res?.canOperate;
this.openDate = this.today; this.openDate = this.today;
this.checkFirstOpenCashMigration(); this.checkFirstOpenCashMigration();
this.checkInterestReports();
}); });
this.service this.service
+17
View File
@@ -205,6 +205,23 @@ export class CashierService {
return this.http.get('/branchday', { params }); 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. */ /** Days opened and never closed — today's own open day excluded. */
branchDayMissed(officeId?: number): Observable<any> { branchDayMissed(officeId?: number): Observable<any> {
let params = new HttpParams(); let params = new HttpParams();
@@ -5804,13 +5804,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return; 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 // Use the dialog-confirmed amount — for cash that is the denomination total
if ( if (
!this.isClerk && !this.isClerk &&
!this.reviewMode && !this.reviewMode &&
isDeposit && 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); this.submitCashierDepositRequest(row, this.confirmData?.amount ?? amount);
return; return;
@@ -6033,7 +6041,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.editRequest = req; 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]; const row = this.rows[0];
row.acNo = req.account_no ?? ''; row.acNo = req.account_no ?? '';
row.acTyp = acTyp; row.acTyp = acTyp;
@@ -643,8 +643,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.createClientForm.get('degreeQualificationId')?.setValue(''); this.createClientForm.get('degreeQualificationId')?.setValue('');
} }
}); });
// Fetch all constitution options from code ID 17 // Constitution options by code NAME, never by id: code ids differ per tenant (on abc_bank id 17 is the empty
this.clientService.getCodeValuesById(17).subscribe((options) => { // ClientClassification code and Constitution is id 24), so a hardcoded id left the dropdown empty.
this.clientService.getCodeValuesByName('Constitution').subscribe((options) => {
if (options?.length) { if (options?.length) {
this.constitutionOptions = options; this.constitutionOptions = options;
// Sort master by value length descending so longer/more-specific keys match first // 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 kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched
) { ) {
<mat-error>Only letters, numbers, hyphens and slashes allowed</mat-error> <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> </mat-form-field>
@@ -8,7 +8,14 @@
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core'; import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { forkJoin, catchError, of } from 'rxjs'; 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 { import {
MatDialog, MatDialog,
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
@@ -567,6 +574,28 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Existing signature document ID (for re-upload/delete) */ /** Existing signature document ID (for re-upload/delete) */
existingSignatureDocId: string = ''; 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 */ /** KYC Form */
kycForm: UntypedFormGroup = this.formBuilder.group({ kycForm: UntypedFormGroup = this.formBuilder.group({
hasPan: [true], hasPan: [true],
@@ -616,7 +645,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
[ [
Validators.required, Validators.required,
Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/), Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/),
Validators.maxLength(30) Validators.maxLength(30),
this.aadhaarLast4MatchValidator
] ]
], ],
kycDocIssueDate: [''], kycDocIssueDate: [''],
@@ -739,6 +769,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycForm.get('ecgcSalFlag')?.disable(); this.kycForm.get('ecgcSalFlag')?.disable();
this.kycForm.get('dormantFlag')?.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, /** 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 // Reset uploads + merged preview when document type changes
this.kycDocFiles = { front: null, back: null }; this.kycDocFiles = { front: null, back: null };
this.kycDocMergedPreview = 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 { isKycDocSelected(proof: ProofType): boolean {
@@ -1189,6 +1230,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Sync chip-picker selection with the prefilled document name // Sync chip-picker selection with the prefilled document name
if (data.kycDocumentName) { if (data.kycDocumentName) {
this.selectedKycDoc = this.findKycDocProofByName(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 // Set verification statuses from existing data
if (data.panVerified) { 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 { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component'; import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
import { Form121TabComponent } from './clients-view/form121-tab/form121-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 { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
import { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component'; import { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component';
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard'; import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
@@ -198,6 +199,7 @@ const routes: Routes = [
{ {
path: 'form121', path: 'form121',
component: Form121TabComponent, component: Form121TabComponent,
resolve: { tdsConfig: tdsConfigResolver },
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false } data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
}, },
{ {
@@ -202,9 +202,14 @@
<div> <div>
<strong>Already available</strong> <strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span> <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>
</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"> <div class="ckyc-existing__thumbs">
@if (existing.ADDRESS_PROOF.previewUrl) { @if (existing.ADDRESS_PROOF.previewUrl) {
<img <img
@@ -222,6 +227,17 @@
} }
</div> </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"> <div class="ckyc-existing__meta">
<fa-icon icon="file-alt"></fa-icon> <fa-icon icon="file-alt"></fa-icon>
<span>{{ existing.ADDRESS_PROOF.name }}</span> <span>{{ existing.ADDRESS_PROOF.name }}</span>
@@ -299,9 +315,12 @@
<div> <div>
<strong>Already available</strong> <strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span> <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>
</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"> <div class="ckyc-existing__thumbs">
@if (existing.PHOTO_PROOF.previewUrl) { @if (existing.PHOTO_PROOF.previewUrl) {
<img <img
@@ -319,6 +338,17 @@
} }
</div> </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"> <div class="ckyc-existing__meta">
<fa-icon icon="id-card"></fa-icon> <fa-icon icon="id-card"></fa-icon>
<span>{{ existing.PHOTO_PROOF.name }}</span> <span>{{ existing.PHOTO_PROOF.name }}</span>
@@ -393,11 +423,25 @@
<div> <div>
<strong>Already available</strong> <strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_IMAGE.source }}</span> <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>
</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" /> <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 <button
mat-stroked-button mat-stroked-button
type="button" type="button"
@@ -448,15 +492,29 @@
<div> <div>
<strong>Already available</strong> <strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_SIGNATURE.source }}</span> <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>
</div> </div>
@if (existing.CLIENT_SIGNATURE.previewUrl) { @if (existing.CLIENT_SIGNATURE.previewUrl && isImage('CLIENT_SIGNATURE')) {
<img <img
[src]="existing.CLIENT_SIGNATURE.previewUrl" [src]="existing.CLIENT_SIGNATURE.previewUrl"
alt="Client signature" alt="Client signature"
class="ckyc-existing__thumb ckyc-existing__thumb--wide" 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"> <div class="ckyc-existing__meta">
<fa-icon icon="file-signature"></fa-icon> <fa-icon icon="file-signature"></fa-icon>
<span>{{ existing.CLIENT_SIGNATURE.name }}</span> <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 { .ckyc-existing {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -36,11 +36,41 @@ interface ExistingArtefact {
* already writes/reads for address proof; now shared by the photo/identity proof too. */ * already writes/reads for address proof; now shared by the photo/identity proof too. */
description?: string; description?: string;
fileCount?: number; 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 ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF';
type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE'; 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({ @Component({
selector: 'mifosx-ckyc-tab', selector: 'mifosx-ckyc-tab',
templateUrl: './ckyc-tab.component.html', templateUrl: './ckyc-tab.component.html',
@@ -150,6 +180,11 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
this.route.parent.params.subscribe((params: any) => { this.route.parent.params.subscribe((params: any) => {
this.clientId = params['clientId']; 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.loadClientInfo();
this.loadAccounts(); this.loadAccounts();
this.loadCkycData(); 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 { private loadClientInfo(): void {
this.route.parent.data.subscribe((data: any) => { this.route.parent.data.subscribe((data: any) => {
const cv = data?.clientViewData; const cv = data?.clientViewData;
@@ -280,19 +355,29 @@ export class CkycTabComponent implements OnInit, OnDestroy {
present: true, present: true,
source: 'Client registration', source: 'Client registration',
name: signatureDoc.fileName || 'Client signature', 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 this.clientsService
.getClientSignatureImage(this.clientId, signatureDoc.id) .downloadClientDocument(this.clientId, String(signatureDoc.id), true)
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe({ .subscribe({
next: (blob: any) => { next: (blob: any) => {
if (blob) { if (blob && blob.size > 0) {
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); 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 : []; const list = Array.isArray(docs) ? docs : [];
if (!list.length) return; if (!list.length) return;
this.existing[kind].fileCount = list.length; this.existing[kind].fileCount = list.length;
this.existing[kind].previewUnavailable = false;
const byNewest = (a: any, b: any) => b.id - a.id; const byNewest = (a: any, b: any) => b.id - a.id;
const label = (d: any) => (d?.name || d?.fileName || '').toString(); 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) => /front/i.test(label(d))).sort(byNewest)[0] ||
[...list].filter((d: any) => d.id !== back?.id).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 (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front');
if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back'); 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$)) .pipe(takeUntil(this.destroy$))
.subscribe({ .subscribe({
next: (blob: any) => { next: (blob: any) => {
if (!blob) return; if (!blob || blob.size === 0) {
this.existing[kind].previewUnavailable = true;
return;
}
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
if (slot === 'front') this.existing[kind].previewUrl = url; if (slot === 'front') this.existing[kind].previewUrl = url;
else this.existing[kind].previewUrlBack = 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) => { ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => {
if (!result) return; 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) { if (isProofKind) {
const proofKind = kind as ProofKind; const proofKind = kind as ProofKind;
this.selectedProofType[proofKind] = result.proofType || null; 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 /** 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. */ * client-india-address-step, for address proof) writes into the identifier's description. */
private parseDescriptionDates(description: string | undefined): { issueDate: Date | null; expiryDate: Date | null } { 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({ request$.pipe(takeUntil(this.destroy$)).subscribe({
next: () => { next: () => {
this.uploading[documentType] = false; 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.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' });
this.refreshPreview(documentType); this.refreshPreview(documentType);
}, },
@@ -653,13 +825,22 @@ export class CkycTabComponent implements OnInit, OnDestroy {
if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) { if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) {
URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!); 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) { switch (documentType) {
case 'CLIENT_IMAGE': case 'CLIENT_IMAGE':
this.clientsService this.clientsService
.getClientProfileImage(this.clientId) .getClientProfileImage(this.clientId)
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe((photo: any) => { .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; break;
case 'CLIENT_SIGNATURE': case 'CLIENT_SIGNATURE':
@@ -672,11 +853,21 @@ export class CkycTabComponent implements OnInit, OnDestroy {
?.sort((a: any, b: any) => b.id - a.id)?.[0]; ?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (!doc) return; if (!doc) return;
this.existing.CLIENT_SIGNATURE.documentId = doc.id; this.existing.CLIENT_SIGNATURE.documentId = doc.id;
this.existing.CLIENT_SIGNATURE.mimeType = doc.type;
this.clientsService this.clientsService
.getClientSignatureImage(this.clientId, doc.id) .downloadClientDocument(this.clientId, String(doc.id), true)
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe((blob: any) => { .subscribe({
if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob); 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; break;
@@ -690,10 +881,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();
// Release object URLs this.revokePreviewUrls();
Object.values(this.existing).forEach((e) => {
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
});
} }
} }
@@ -55,7 +55,10 @@
<div class="f121__note"> <div class="f121__note">
<mat-icon>info_outline</mat-icon> <mat-icon>info_outline</mat-icon>
<div> <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 Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
Data tab. Data tab.
} @else { } @else {
@@ -153,6 +153,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
schedule: TdsSchedule | null = null; schedule: TdsSchedule | null = null;
actualTds = 0; actualTds = 0;
tdsSaved = 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. */ /** Raw account inputs, kept so the year selector can recompute cheaply. */
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = []; private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
@@ -212,7 +214,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
this.panWellFormed = isWellFormedPan(this.pan); this.panWellFormed = isWellFormedPan(this.pan);
this.declarationOnRecord = !!kyc?.form15Applicable; this.declarationOnRecord = !!kyc?.form15Applicable;
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G'; this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121'; this.declarationForm =
this.fy && FY_TAX_CONFIG[normaliseFy(this.fy)] ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
this.declarationFy = kyc?.form15FY ?? ''; this.declarationFy = kyc?.form15FY ?? '';
this.acceptanceDateKnown = false; this.acceptanceDateKnown = false;
} }
@@ -333,6 +336,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
} }
const year = normaliseFy(this.fy); const year = normaliseFy(this.fy);
this.configMissing = !FY_TAX_CONFIG[year];
// Per-account interest for the selected year, for the three lists. // Per-account interest for the selected year, for the three lists.
for (const a of this.accountInputs) { for (const a of this.accountInputs) {
@@ -340,7 +344,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
} }
this.actualTds = this.actualTdsByFy.get(year) ?? 0; this.actualTds = this.actualTdsByFy.get(year) ?? 0;
if (!this.dateOfBirth) { if (!this.dateOfBirth || this.configMissing) {
return; return;
} }
@@ -411,7 +415,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
/** The form the payee would file for the selected year. */ /** The form the payee would file for the selected year. */
get formLabel(): string { get formLabel(): string {
if (!this.fy) { if (!this.fy || this.configMissing) {
return '—'; return '—';
} }
const form = declarationFormFor(this.fy, this.dateOfBirth); const form = declarationFormFor(this.fy, this.dateOfBirth);
@@ -1247,9 +1247,7 @@
</div> </div>
@if (!docScansLoading && !docScans.front && !docScans.back) { @if (!docScansLoading && !docScans.front && !docScans.back) {
<div class="kyc-doc-scans__empty"> <div class="kyc-doc-scans__empty">No scan is on file for this document. Upload one below.</div>
No scan is on file for this document. Upload one below.
</div>
} }
<div class="kyc-doc-scans__sides"> <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 }; 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. */ /** 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') { docScanOf(slot: 'front' | 'back') {
return this.docScans[slot]; return this.docScans[slot];
@@ -474,7 +477,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
adverseMediaMatch: !!v.adverseMediaMatch, adverseMediaMatch: !!v.adverseMediaMatch,
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a // 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. // 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: declaredAnnualTurnover:
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
}; };
@@ -519,7 +523,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat: 'dd MMMM yyyy', dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(), ...this.buildDocumentFields(),
...this.buildRiskProfileFields(), ...this.buildRiskProfileFields(),
...this.buildExceptionFields() ...this.buildExceptionFields()
}; };
if (!this.kycId) { if (!this.kycId) {
@@ -766,7 +770,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
riskCategory: this.kycForm.value.riskCategory || 1, riskCategory: this.kycForm.value.riskCategory || 1,
...declarationFields, ...declarationFields,
...this.buildRiskProfileFields(), ...this.buildRiskProfileFields(),
...this.buildExceptionFields() ...this.buildExceptionFields()
}; };
this.clientsService this.clientsService
@@ -927,7 +931,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
riskCategory: this.kycForm.value.riskCategory || 1, riskCategory: this.kycForm.value.riskCategory || 1,
...receiptFields, ...receiptFields,
...this.buildRiskProfileFields(), ...this.buildRiskProfileFields(),
...this.buildExceptionFields() ...this.buildExceptionFields()
}; };
this.clientsService this.clientsService
@@ -1455,7 +1459,10 @@ I further confirm that:
this.docScansLoading = false; this.docScansLoading = false;
// This runs again after every replace, so the previews being thrown away are revoked // This runs again after every replace, so the previews being thrown away are revoked
// rather than left holding their blobs. // 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) { if (old?.previewUrl) {
URL.revokeObjectURL(old.previewUrl); URL.revokeObjectURL(old.previewUrl);
} }
@@ -1497,7 +1504,10 @@ I further confirm that:
} }
this.docScans = sides; this.docScans = sides;
for (const key of ['front', 'back'] as const) { for (const key of [
'front',
'back'
] as const) {
if (sides[key]) { if (sides[key]) {
this.loadScanPreview(sides[key]!); this.loadScanPreview(sides[key]!);
} }
@@ -1558,7 +1568,9 @@ I further confirm that:
error: (err: any) => { error: (err: any) => {
this.docScanUploading = null; this.docScanUploading = null;
this.docScanError = 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$.next();
this.destroy$.complete(); this.destroy$.complete();
// Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab. // 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) { if (side?.previewUrl) {
URL.revokeObjectURL(side.previewUrl); URL.revokeObjectURL(side.previewUrl);
} }
+33 -6
View File
@@ -569,8 +569,19 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/documents`); 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) { uploadClientDocument(clientId: string, documentData: any) {
@@ -992,12 +1003,28 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data); 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);
} }
/** /**
@@ -109,13 +109,7 @@
</button> </button>
} }
</div> </div>
<input <input #sideInput type="file" hidden accept="image/*,application/pdf" (change)="onFilePicked($event, slot)" />
#sideInput
type="file"
hidden
accept="image/*,application/pdf"
(change)="onFilePicked($event, slot)"
/>
</div> </div>
} }
</div> </div>
+19 -1
View File
@@ -25,6 +25,24 @@ import { environment } from '../../../environments/environment';
export class CacheInterceptor implements HttpInterceptor { export class CacheInterceptor implements HttpInterceptor {
private forceUpdate = false; 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',
'/savingsinterestpostingreports'
];
private isCacheable(url: string): boolean {
return !CacheInterceptor.NEVER_CACHE.some((fragment) => url.includes(fragment));
}
// eslint-disable-next-line @angular-eslint/prefer-inject // eslint-disable-next-line @angular-eslint/prefer-inject
constructor(private httpCacheService: HttpCacheService) {} constructor(private httpCacheService: HttpCacheService) {}
@@ -42,7 +60,7 @@ export class CacheInterceptor implements HttpInterceptor {
} }
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { 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); return next.handle(request);
} }
+45 -2
View File
@@ -65,6 +65,14 @@
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span> <span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
</nav> </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/* --> <!-- Clerk Dashboard fullscreen nav — only visible at /clerk/* -->
@else if (currentUrl.includes('/clerk/')) { @else if (currentUrl.includes('/clerk/')) {
<nav class="cashier-nav cashier-nav--clerk"> <nav class="cashier-nav cashier-nav--clerk">
@@ -655,6 +663,12 @@
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0"> <button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted <i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button> </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, 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. so it never shows up under the report categories above and had no nav entry at all.
@@ -1203,8 +1217,8 @@
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0">
<fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry <fa-icon icon="clock" class="menu-item-icon"></fa-icon> Account Expiry
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0" [routerLink]="['/fd-dashboard']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> FD Maturity Report <fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> FD Dashboard
</button> </button>
<button mat-menu-item tabindex="0"> <button mat-menu-item tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report <fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
@@ -2063,6 +2077,35 @@
<fa-icon icon="users" class="gl-icon" style="color: #4f46e5"></fa-icon> Members &amp; Share Capital <fa-icon icon="users" class="gl-icon" style="color: #4f46e5"></fa-icon> Members &amp; Share Capital
</a> </a>
</div> </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 --> <!-- CIMS / XBRL Returns -->
<div class="gl-group"> <div class="gl-group">
<div class="gl-group-title">CIMS / XBRL Returns</div> <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/withdrawal-request') ||
url.includes('/clerk/my-disbursement-requests'); url.includes('/clerk/my-disbursement-requests');
const isElectionRoute = url.includes('/election'); 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.cdr.detectChanges();
}; };
this.routeSub = this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => checkLayout()); 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. */
* Toggles the current collapsed state of sidenav according to the emitted event.
* @param {boolean} event denotes state of sidenav
*/
toggleCollapse($event: boolean) { toggleCollapse($event: boolean) {
this.sidenavCollapsed = $event; this.sidenavCollapsed = $event;
this.cdr.detectChanges(); 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); 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') @HostListener('document:keydown.escape')
onEscapeKey(): void { onEscapeKey(): void {
if ( if (
this.hideShellLayout && 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.hideShellLayout = false;
this.cdr.detectChanges(); this.cdr.detectChanges();
@@ -8,7 +8,10 @@
<div class="cur-view"> <div class="cur-view">
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── --> <!-- ─── 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 ─────────────────────────────────────── --> <!-- ─── Gradient hero header ─────────────────────────────────────── -->
<header class="cur-hero"> <header class="cur-hero">
@@ -59,16 +59,7 @@
<span class="nx-mono">[{{ currentAccountData.currency.code }}]</span> <span class="nx-mono">[{{ currentAccountData.currency.code }}]</span>
</span> </span>
</div> </div>
<div class="nx-row"> <!-- A current account earns no interest, so no interest rate is shown here; OD interest is under Overdraft. -->
<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>
} @else { } @else {
<div class="nx-row"> <div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Field Officer' | translate }}</span> <span class="nx-row__label">{{ 'labels.inputs.Field Officer' | translate }}</span>
@@ -102,17 +93,17 @@
</span> </span>
</div> </div>
} }
@if (currentAccountData.summary.totalInterestEarned >= 0) { @if (currentAccountData.summary.totalWithdrawals) {
<div class="nx-row"> <div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Total Interest Earned' | translate }}</span> <span class="nx-row__label">{{ 'labels.inputs.Total Withdrawls' | translate }}</span>
<span class="nx-row__value nx-row__value--amount"> <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> </span>
</div> </div>
} }
@if (!currentAccountData.summary.totalDeposits && !(currentAccountData.summary.totalInterestEarned >= 0)) { @if (!currentAccountData.summary.totalDeposits && !currentAccountData.summary.totalWithdrawals) {
<div class="nx-empty"> <div class="nx-empty">
<fa-icon icon="inbox" class="nx-empty__ico"></fa-icon> <fa-icon icon="inbox" class="nx-empty__ico"></fa-icon>
<span>No activity yet.</span> <span>No activity yet.</span>
@@ -132,16 +123,7 @@
<h3 class="nx-card__title">{{ 'labels.heading.Account Summary' | translate }}</h3> <h3 class="nx-card__title">{{ 'labels.heading.Account Summary' | translate }}</h3>
</header> </header>
<div class="nx-card__body"> <div class="nx-card__body">
@if (currentAccountData.summary.totalWithdrawals) { <!-- A current account earns no interest: no interest earned/posted rows. OD interest has its own card. -->
<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>
}
@if (currentAccountData.summary.totalWithdrawalFees) { @if (currentAccountData.summary.totalWithdrawalFees) {
<div class="nx-row"> <div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withdrawals Fees' | translate }}</span> <span class="nx-row__label">{{ 'labels.inputs.Withdrawals Fees' | translate }}</span>
@@ -160,89 +142,6 @@
</span> </span>
</div> </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) { @if (currentAccountData.withdrawalFee) {
<div class="nx-row"> <div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withdrawal Fee' | translate }}</span> <span class="nx-row__label">{{ 'labels.inputs.Withdrawal Fee' | translate }}</span>
@@ -289,37 +188,6 @@
</span> </span>
</div> </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) { @if (currentAccountData.minRequiredBalance) {
<div class="nx-row"> <div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Minimum Required Balance' | translate }}</span> <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> <span class="nx-row__value">{{ currentAccountData.enforceMinRequiredBalance | yesNo }}</span>
</div> </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) { @if (currentAccountData.onHoldFunds) {
<div class="nx-row"> <div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.On Hold Funds' | translate }}</span> <span class="nx-row__label">{{ 'labels.inputs.On Hold Funds' | translate }}</span>
@@ -350,14 +210,72 @@
</span> </span>
</div> </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> </div>
</section> </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 { } @else {
<section class="nx-card"> <section class="nx-card">
<header class="nx-card__head"> <header class="nx-card__head">
@@ -54,8 +54,8 @@
<mat-icon>account_balance</mat-icon> <mat-icon>account_balance</mat-icon>
</div> </div>
<div class="bank-name-block"> <div class="bank-name-block">
<div class="bank-title">LincPay Co-operative Bank Ltd.</div> <div class="bank-title">{{ bankName }}</div>
<div class="bank-subtitle">Current Account Statement</div> <div class="bank-subtitle">{{ bankSubtitle }}</div>
</div> </div>
</div> </div>
<div class="statement-label"> <div class="statement-label">
@@ -64,6 +64,25 @@
</div> </div>
</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 --> <!-- Account Details -->
<div class="account-details-band"> <div class="account-details-band">
<div class="account-details-grid"> <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 ────────────────────────────────────────────────────
.account-details-band { .account-details-band {
background: #f0f3f9; background: #f0f3f9;
@@ -11,11 +11,12 @@ import { Component, OnInit, OnDestroy, inject, ElementRef, ViewChild } from '@an
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder } from '@angular/forms'; import { UntypedFormBuilder } from '@angular/forms';
import { CurrencyPipe } from '@angular/common'; import { CurrencyPipe } from '@angular/common';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil, catchError, forkJoin, of } from 'rxjs';
/** Custom Services */ /** Custom Services */
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { OrganizationService } from '../../../organization/organization.service';
import { Currency } from 'app/shared/models/general.model'; import { Currency } from 'app/shared/models/general.model';
import { import {
CurrentAccountTransaction, CurrentAccountTransaction,
@@ -44,6 +45,7 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private organizationService = inject(OrganizationService);
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@@ -60,6 +62,17 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
availableBalance: number = 0; availableBalance: number = 0;
activatedOnDate: any = null; 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 */ /** Transactions */
allTransactions: CurrentAccountTransaction[] = []; allTransactions: CurrentAccountTransaction[] = [];
filteredTransactions: CurrentAccountTransaction[] = []; filteredTransactions: CurrentAccountTransaction[] = [];
@@ -92,6 +105,33 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
fromDate: [''], fromDate: [''],
toDate: [''] 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[] { private computeRunningBalances(transactions: CurrentAccountTransaction[]): CurrentAccountTransaction[] {
@@ -279,6 +319,18 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
margin-top: 3px; 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 */
.account-details-band { .account-details-band {
background: #f0f3f9; background: #f0f3f9;
@@ -334,14 +334,19 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit); const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
if (this.principalAmount && this.principalAmount > 0) { if (this.principalAmount && this.principalAmount > 0) {
// Convert tenure to months for backend API // Years and Months convert exactly; a Days tenure is sent as days and never converted.
let tenureInMonths = 0; let tenureInMonths: number | undefined;
let tenureInDays: number | undefined;
if (this.tenurePeriodUnit === 'Years') { if (this.tenurePeriodUnit === 'Years') {
tenureInMonths = this.tenureValue * 12; tenureInMonths = this.tenureValue * 12;
} else if (this.tenurePeriodUnit === 'Months') { } else if (this.tenurePeriodUnit === 'Months') {
tenureInMonths = this.tenureValue; tenureInMonths = this.tenureValue;
} else { } 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) // 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, principalAmount: this.principalAmount,
annualInterestRate: rate, annualInterestRate: rate,
tenureInMonths, tenureInMonths,
tenureInDays,
interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend
interestPostingPeriodInMonths: postMonths, interestPostingPeriodInMonths: postMonths,
submittedOnDate: submittedOnDateStr, submittedOnDate: submittedOnDateStr,
@@ -417,10 +423,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100; Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100;
} else { } else {
const n = 12 / compMonths; const n = 12 / compMonths;
const totalCompoundings = Math.floor(tenureInMonths / compMonths); const effectiveMonths = tenureInMonths ?? enteredDays / 30;
const rPerCompounding = rate / (n * 100); const rPerCompounding = rate / (n * 100);
result.maturityAmount = // Whole compounding periods, then simple interest on the days left over. Flooring the
Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 100) / 100; // 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; result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100;
this.matchResult = { ...result }; this.matchResult = { ...result };
@@ -7,17 +7,29 @@
--> -->
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column"> <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-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
<mat-select formControlName="maturityInstructionId"> <mat-select formControlName="maturityInstructionId">
<mat-option [value]="''">-- Not selected (decide later via FD Dashboard) --</mat-option>
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) { @for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
<mat-option [value]="maturityInstruction.id"> <mat-option [value]="maturityInstruction.id">
{{ maturityInstruction.value | translateKey: 'catalogs' }} {{ maturityInstruction.value | translateKey: 'catalogs' }}
</mat-option> </mat-option>
} }
</mat-select> </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> </mat-form-field>
@if (closureSettingsForm.contains('transferToSavingsId')) { @if (closureSettingsForm.contains('transferToSavingsId')) {
@@ -28,8 +40,8 @@
<mat-icon>savings</mat-icon> <mat-icon>savings</mat-icon>
</div> </div>
<div> <div>
<h3 class="dal-card__title">{{ 'labels.inputs.Transfer to savings account' | translate }}</h3> <h3 class="dal-card__title">{{ transferAccountCardTitle }}</h3>
<span class="dal-card__sub">Maturity proceeds will be credited to this account</span> <span class="dal-card__sub">{{ transferAccountCardSubtitle }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -69,7 +81,7 @@
</div> </div>
} @else { } @else {
<mat-form-field appearance="outline" class="flex-98"> <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> <mat-select formControlName="transferToSavingsId" required>
@for (account of savingsAccountsData; track account) { @for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id"> <mat-option [value]="account.id">
@@ -77,13 +89,15 @@
</mat-option> </mat-option>
} }
</mat-select> </mat-select>
<mat-error>Transfer to savings account is required</mat-error> <mat-error>{{ transferAccountCardTitle }} is required</mat-error>
</mat-form-field> </mat-form-field>
} }
</div> </div>
</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"> <mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }} {{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox> </mat-checkbox>
@@ -11,6 +11,7 @@ import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/cor
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms'; import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon'; import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** /**
@@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatCheckbox, MatCheckbox,
MatIcon MatIcon,
MatIconButton
] ]
}) })
export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges { export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges {
@@ -53,9 +55,12 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges
preClosurePenalInterestOnTypeId: [''] preClosurePenalInterestOnTypeId: ['']
}); });
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => { this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
// 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both // 200 = Transfer to Savings -- the whole matured balance goes here. 400 = Reinvest Principal
// Reinvest options (300/400) open a brand-new FD instead, so they ignore this field too. // Only -- the principal rolls into a new FD, but the leftover interest still needs the same
if (value === 200) { // 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( this.closureSettingsForm.addControl(
'transferToSavingsId', 'transferToSavingsId',
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required) 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 { get selectedTransferAccount(): any {
const id = this.closureSettingsForm.value.transferToSavingsId; const id = this.closureSettingsForm.value.transferToSavingsId;
if (!id) return null; if (!id) return null;
return this.savingsAccountsData.find((account: any) => account.id === id) ?? 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 { clearTransferAccount(): void {
this.closureSettingsForm.get('transferToSavingsId')?.setValue(''); this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
} }
@@ -8,11 +8,24 @@
<div class="pmt"> <div class="pmt">
<div class="pmt__header"> <div class="pmt__header">
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span> <div class="pmt__header-left">
<div class="pmt__header-text"> <span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
<h2 class="pmt__title">{{ 'labels.heading.Payment' | translate }}</h2> <div class="pmt__header-text">
<p class="pmt__subtitle">Process this Fixed Deposit now -- works whether it's Active or Matured.</p> <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> </div>
<button type="button" class="pmt__header-close" mat-dialog-close [disabled]="submitting" title="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div> </div>
<div mat-dialog-content class="pmt__content"> <div mat-dialog-content class="pmt__content">
@@ -27,37 +40,92 @@
</div> </div>
} @else { } @else {
<form [formGroup]="paymentForm"> <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) { @if (reinvestCap !== null && reinvestCap !== undefined) {
<div class="pmt__stat"> <div class="pmt__stat">
<span class="pmt__stat-label"> <span class="pmt__stat-label">
{{ {{
remainderIsApproximate isMaturedRenewal || !remainderIsApproximate
? 'Amount available on the selected date' ? ('labels.inputs.Maturity Amount' | translate)
: ('labels.inputs.Maturity Amount' | translate) : 'Amount available on the selected date'
}} }}
</span> </span>
<span class="pmt__stat-value">{{ reinvestCap | formatNumber }}</span> <span class="pmt__stat-value">{{ reinvestCap | formatNumber }}</span>
</div> </div>
} }
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()"> @if (!isMaturedRenewal) {
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label> <mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<input <mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
matInput <input
[max]="maxDate" matInput
[matDatepicker]="closedOnDatePicker" [max]="maxDate"
required [matDatepicker]="closedOnDatePicker"
formControlName="closedOnDate" required
/> formControlName="closedOnDate"
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle> />
<mat-datepicker #closedOnDatePicker></mat-datepicker> <mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
@if (paymentForm.controls.closedOnDate.hasError('required')) { <mat-datepicker #closedOnDatePicker></mat-datepicker>
<mat-error> @if (paymentForm.controls.closedOnDate.hasError('required')) {
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }} <mat-error>
<strong>{{ 'labels.commons.required' | translate }}</strong> {{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
</mat-error> <strong>{{ 'labels.commons.required' | translate }}</strong>
} </mat-error>
</mat-form-field> }
</mat-form-field>
}
<div class="pmt__section-label" id="pmt-action-label">{{ 'labels.inputs.Action' | translate }}</div> <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"> <div class="pmt__options" role="radiogroup" aria-labelledby="pmt-action-label">
@@ -162,10 +230,12 @@
</mat-form-field> </mat-form-field>
} }
<mat-form-field appearance="outline"> @if (!isMaturedRenewal) {
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label> <mat-form-field appearance="outline">
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea> <mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
</mat-form-field> <textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
}
@if (submitError) { @if (submitError) {
<p class="pmt__error" role="alert"> <p class="pmt__error" role="alert">
@@ -178,14 +248,25 @@
</div> </div>
<div mat-dialog-actions align="end" class="pmt__actions"> <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 <button
mat-raised-button mat-raised-button
color="primary" color="primary"
class="pmt__btn-submit"
[disabled]="!paymentForm?.valid || submitting || loadFailed" [disabled]="!paymentForm?.valid || submitting || loadFailed"
(click)="submit()" (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> </button>
</div> </div>
</div> </div>
@@ -6,291 +6,466 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
$navy: #0d1b2a; /**
$slate: #475569; * Payment / Renewal Dialog Styles
$muted: #64748b; * CBSNexora Core Banking System
$border: #e2e8f0; */
$soft: #f8fafc;
$blue: #1e40af;
$blue-soft: #eff6ff;
$green: #166534;
$green-soft: #f0fdf4;
.pmt { .pmt {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 320px; height: 100%;
max-height: 85vh; max-height: 92vh;
box-sizing: border-box;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
color: #0f172a;
border-radius: 16px;
overflow: hidden; overflow: hidden;
margin: -24px; box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%);
} }
.pmt__header { .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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
font-size: 18px; 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 { .pmt__title {
margin: 0; margin: 0;
font-size: 19px; font-size: 20px;
font-weight: 700; font-weight: 800;
color: #fff;
line-height: 1.25;
letter-spacing: -0.01em;
} }
.pmt__subtitle { .pmt__subtitle {
margin: 4px 0 0; margin: 5px 0 0;
font-size: 12.5px; font-size: 13px;
color: rgb(255 255 255 / 82%); color: rgb(255 255 255 / 88%);
line-height: 1.4; line-height: 1.4;
} }
.pmt__content { .pmt__content {
padding: 22px 28px 8px !important; padding: 26px 30px !important;
overflow-y: auto; overflow-y: auto;
flex: 1 1 auto; flex: 1 1 auto;
background: #fbfcfe;
mat-form-field {
width: 100%;
}
} }
.pmt__loading { .pmt__loading {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 260px; min-height: 240px;
} }
.pmt__stat { .pmt__stat {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 4px;
background: $blue-soft; background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%);
border: 1px solid #dbeafe; border: 1px solid #bfdbfe;
border-radius: 10px; border-radius: 14px;
padding: 12px 16px; padding: 18px 22px;
margin-bottom: 18px; 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 { .pmt__preview {
font-size: 11px; display: flex;
font-weight: 700; flex-direction: column;
text-transform: uppercase; background: #fff;
letter-spacing: 0.05em; border: 1px solid #e2e8f0;
color: $blue; border-radius: 14px;
} padding: 4px 18px;
margin: 0 0 18px;
.pmt__stat-value { &-row {
font-size: 22px; display: flex;
font-weight: 800; align-items: center;
color: $navy; justify-content: space-between;
font-variant-numeric: tabular-nums; gap: 12px;
} padding: 11px 0;
border-bottom: 1px solid #f1f5f9;
.pmt__date { &:last-child {
width: 100%; 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 { .pmt__section-label {
font-size: 11.5px; font-size: 12px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.06em;
color: $slate; color: #475569;
margin: 4px 0 10px; margin: 10px 0 12px;
} }
/* Action Options Grid - 2 Equal Columns, comfortably wide now that the dialog itself is wider */
.pmt__options { .pmt__options {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); grid-template-columns: repeat(2, 1fr);
gap: 10px; gap: 14px;
margin-bottom: 6px; margin-bottom: 18px;
@media (width <= 560px) {
grid-template-columns: 1fr;
}
} }
.pmt__option { .pmt__option {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 14px;
text-align: left; text-align: left;
background: #fff; background: #fff;
border: 1.5px solid $border; border: 1.5px solid #e2e8f0;
border-radius: 10px; border-radius: 14px;
padding: 12px 14px; padding: 16px;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
transition: transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border-color 0.15s, position: relative;
background 0.15s,
box-shadow 0.15s;
}
.pmt__option:hover { &:hover {
border-color: #93c5fd; border-color: #93c5fd;
background: $blue-soft; background: #f8fafc;
} box-shadow: 0 6px 16px rgb(15 23 42 / 6%);
transform: translateY(-1px);
}
.pmt__option--active { &--active {
border-color: $blue; border-color: #2563eb;
background: $blue-soft; background: #eff6ff;
box-shadow: 0 0 0 1px $blue; box-shadow: 0 0 0 1.5px #2563eb;
}
.pmt__option-ico { .pmt__option-ico {
width: 30px; background: #2563eb;
height: 30px; color: #fff;
min-width: 30px; border-color: #2563eb;
border-radius: 8px; }
background: $soft; }
color: $blue;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
.pmt__option--active .pmt__option-ico { &-ico {
background: $blue; width: 38px;
color: #fff; 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 { // Distinct accent per option while unselected, purely presentational -- helps the four cards read
display: flex; // apart at a glance. Selecting a card always fills its icon blue (see &--active above), matching the
flex-direction: column; // blue selection border/check regardless of which option it is, rather than mixing selection-blue
gap: 2px; // with the option's own accent.
} &:not(.pmt__option--active):nth-child(2) .pmt__option-ico {
color: #7c3aed;
}
.pmt__option-title { &:not(.pmt__option--active):nth-child(3) .pmt__option-ico {
font-size: 13px; color: #0d9488;
font-weight: 700; }
color: $navy;
}
.pmt__option-desc { &:not(.pmt__option--active):nth-child(4) .pmt__option-ico {
font-size: 11px; color: #d97706;
color: $muted; }
line-height: 1.35;
}
.pmt__option:focus-visible { &-text {
outline: 2px solid $blue; display: flex;
outline-offset: 2px; flex-direction: column;
} gap: 4px;
flex: 1;
}
.pmt__option-check { &-title {
margin-left: auto; font-size: 14px;
color: $blue; font-weight: 700;
font-size: 13px; 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 { .pmt__option-error {
color: #b91c1c; color: #b91c1c;
font-size: 12px; font-size: 12px;
margin: 4px 0 14px; font-weight: 600;
} margin: -10px 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;
} }
.pmt__split { .pmt__split {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 16px;
background: $soft; background: #f8fafc;
border: 1px dashed #cbd5e1; border: 1px dashed #cbd5e1;
border-radius: 10px; border-radius: 14px;
padding: 14px 16px 0; padding: 18px;
margin: 4px 0 8px; margin: 10px 0 18px;
flex-wrap: wrap; 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 { .pmt__hint {
flex: 1 1 180px;
min-width: 160px;
}
.pmt__split-arrow {
color: $muted;
font-size: 14px;
margin-bottom: 20px;
}
.pmt__split-remainder {
display: flex; display: flex;
flex-direction: column; align-items: flex-start;
gap: 2px; gap: 10px;
background: $green-soft; background: #f0f9ff;
border: 1px solid #bbf7d0; border: 1px solid #bae6fd;
border-radius: 8px; border-left: 4px solid #0284c7;
padding: 8px 14px; border-radius: 10px;
margin-bottom: 14px; color: #0369a1;
flex: 1 1 180px; font-size: 13px;
min-width: 160px; 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 { .pmt__error {
font-size: 10.5px; display: flex;
font-weight: 700; align-items: flex-start;
text-transform: uppercase; gap: 10px;
letter-spacing: 0.04em; background: #fef2f2;
color: $green; border: 1px solid #fecaca;
} border-left: 4px solid #ef4444;
border-radius: 10px;
.pmt__split-remainder-value { color: #b91c1c;
font-size: 16px; font-size: 13px;
font-weight: 800; padding: 12px 16px;
color: $green; margin: 10px 0;
font-variant-numeric: tabular-nums;
}
.pmt__content mat-form-field {
width: 100%;
} }
.pmt__actions { .pmt__actions {
padding: 14px 28px 20px !important; padding: 18px 30px !important;
border-top: 1px solid $border; border-top: 1px solid #e2e8f0;
flex: 0 0 auto; 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 */ /** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service'; import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe'; import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; 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 * "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, * 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 * 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. * 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({ @Component({
selector: 'mifosx-payment-dialog', selector: 'mifosx-payment-dialog',
@@ -76,11 +87,18 @@ export class PaymentDialogComponent implements OnInit {
}; };
dialogRef = inject<MatDialogRef<PaymentDialogComponent>>(MatDialogRef); 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 formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService); private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private alertService = inject(AlertService);
paymentForm: UntypedFormGroup; paymentForm: UntypedFormGroup;
/** Loaded once, on open, from the same closure template the existing Close action already uses. */ /** 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; submitError: string = null;
maxDate: Date; 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 { ngOnInit(): void {
this.maxDate = this.settingsService.businessDate; this.maxDate = this.settingsService.businessDate;
this.paymentForm = this.formBuilder.group({ this.paymentForm = this.formBuilder.group({
@@ -106,6 +134,34 @@ export class PaymentDialogComponent implements OnInit {
], ],
note: [''] 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)); 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 // 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. // 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. * The amount being split, and the bound the entered partial amount is checked against.
* <p> * <p>
@@ -259,6 +340,31 @@ export class PaymentDialogComponent implements OnInit {
} }
submit(): void { 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 formValue = { ...this.paymentForm.value };
const locale = this.settingsService.language.code; const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat; const dateFormat = this.settingsService.dateFormat;
@@ -271,7 +377,16 @@ export class PaymentDialogComponent implements OnInit {
this.fixedDepositsService this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data) .executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data)
.subscribe({ .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 }); this.dialogRef.close({ confirm: true });
}, },
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a // 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 { 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 { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-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'; import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
/** Custom Button Config. */ /** Custom Button Config. */
@@ -29,6 +30,7 @@ import { AccountsService } from 'app/accounts/accounts.service';
import { Currency } from 'app/shared/models/general.model'; import { Currency } from 'app/shared/models/general.model';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { AuthenticationService } from '../../../core/authentication/authentication.service'; import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, CurrencyPipe } from '@angular/common'; import { NgClass, CurrencyPipe } from '@angular/common';
@@ -73,6 +75,7 @@ import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dor
export class FixedDepositAccountViewComponent implements OnInit { export class FixedDepositAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private alertService = inject(AlertService);
private fixedDepositsService = inject(FixedDepositsService); private fixedDepositsService = inject(FixedDepositsService);
private savingsService = inject(SavingsService); private savingsService = inject(SavingsService);
private accountsService = inject(AccountsService); private accountsService = inject(AccountsService);
@@ -238,7 +241,6 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Undo Activation': case 'Undo Activation':
case 'Add Charge': case 'Add Charge':
case 'Withdrawn by Client': case 'Withdrawn by Client':
case 'Premature Close':
case 'Deposit': case 'Deposit':
case 'Withdrawal': case 'Withdrawal':
this.router.navigate([`actions/${name}`], { relativeTo: this.route }); this.router.navigate([`actions/${name}`], { relativeTo: this.route });
@@ -267,16 +269,21 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Payment': case 'Payment':
this.openPaymentDialog(); this.openPaymentDialog();
break; break;
case 'Premature Close':
this.openPrematureCloseDialog();
break;
} }
} }
/** /**
* Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or * Opens the Payment action -- only offered once the FD has actually Matured (see
* Matured, and is the only place a Partial Reinvest can be done. * 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 { private openPaymentDialog(): void {
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, { const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
width: '640px', width: '720px',
maxWidth: '95vw', maxWidth: '95vw',
data: { data: {
accountId: this.fixedDepositsAccountData.id, 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. * Deletes Fixed Deposits Account.
*/ */
@@ -360,7 +421,16 @@ export class FixedDepositAccountViewComponent implements OnInit {
if (response.confirm) { if (response.confirm) {
this.fixedDepositsService this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.fixedDepositsAccountData.id, 'postInterest', {}) .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(); this.reload();
}); });
} }
@@ -57,11 +57,12 @@ export class FixedDepositsButtonsConfiguration {
]; ];
break; break;
case 'Matured': 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 = [ this.buttonsArray = [
{
name: 'Close',
icon: 'fa fa-arrow-right'
},
{ {
name: 'Calculate Interest', name: 'Calculate Interest',
icon: 'fa fa-table' icon: 'fa fa-table'
@@ -109,7 +110,18 @@ export class FixedDepositsButtonsConfiguration {
setOptions(status: string) { setOptions(status: string) {
switch (status) { switch (status) {
case 'Active': case 'Active':
this.optionArray = [
{
name: 'Post Interest'
},
{
name: 'Add Charge'
}
];
break;
case 'Matured': 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 = [ this.optionArray = [
{ {
name: 'Post Interest' name: 'Post Interest'
@@ -41,8 +41,8 @@
) { ) {
<div class="rh-node"> <div class="rh-node">
<div class="rh-node__rail"> <div class="rh-node__rail">
<span class="rh-node__dot" [class.rh-node__dot--active]="term.renewalstatus === 'ACTIVE'"> <span class="rh-node__dot" [class.rh-node__dot--active]="isTermActive(term, last)">
@if (term.renewalstatus === 'ACTIVE') { @if (isTermActive(term, last)) {
<span class="rh-node__pulse"></span> <span class="rh-node__pulse"></span>
} }
</span> </span>
@@ -51,15 +51,15 @@
} }
</div> </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__head">
<div class="rh-card__title-group"> <div class="rh-card__title-group">
<h4 class="rh-card__title"> <h4 class="rh-card__title">
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }} {{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
</h4> </h4>
<span class="rh-badge" [class.rh-badge--active]="term.renewalstatus === 'ACTIVE'"> <span class="rh-badge" [class.rh-badge--active]="isTermActive(term, last)">
<fa-icon [icon]="term.renewalstatus === 'ACTIVE' ? 'check-circle' : 'clock'"></fa-icon> <fa-icon [icon]="isTermActive(term, last) ? 'check-circle' : 'clock'"></fa-icon>
{{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }} {{ termStatusLabel(term, last) }}
</span> </span>
@if (renewalTypeLabel(term)) { @if (renewalTypeLabel(term)) {
<span class="rh-tag"> <span class="rh-tag">
@@ -95,7 +95,7 @@
</div> </div>
<div class="rh-stat"> <div class="rh-stat">
<span class="rh-stat__label">{{ <span class="rh-stat__label">{{
term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned' isTermActive(term, last) ? 'Interest So Far' : 'Interest Earned'
}}</span> }}</span>
<span class="rh-stat__value rh-stat__value--positive"> <span class="rh-stat__value rh-stat__value--positive">
{{ {{
@@ -106,9 +106,7 @@
</span> </span>
</div> </div>
<div class="rh-stat rh-stat--highlight"> <div class="rh-stat rh-stat--highlight">
<span class="rh-stat__label">{{ <span class="rh-stat__label">{{ maturityLabel(term, last) }}</span>
term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At'
}}</span>
<span class="rh-stat__value"> <span class="rh-stat__value">
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }} {{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
</span> </span>
@@ -183,6 +183,51 @@ export class RenewalHistoryTabComponent implements OnInit {
return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot'; 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. * 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 { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */ /** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service'; import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */ /** Minimum date allowed. */
minDate = new Date(2000, 0, 1); minDate = new Date(2000, 0, 1);
@@ -98,17 +100,31 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
dateFormat, dateFormat,
locale locale
}; };
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'approve', data).subscribe(() => { this.fixedDepositsService
const activationData = { .executeFixedDepositsAccountCommand(this.accountId, 'approve', data)
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate, .subscribe((response: any) => {
dateFormat, // Parked in the Checker Inbox rather than executed (HTTP 200 with rollbackTransaction: true).
locale // 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.
this.fixedDepositsService if (response?.rollbackTransaction) {
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData) this.alertService.alert({
.subscribe(() => { 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 }); 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 { FixedDepositsService } from '../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -43,6 +44,7 @@ export class CloseFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */ /** Minimum date allowed. */
minDate = new Date(2000, 0, 1); minDate = new Date(2000, 0, 1);
@@ -139,8 +141,18 @@ export class CloseFixedDepositsAccountComponent implements OnInit {
dateFormat, dateFormat,
locale locale
}; };
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'close', data).subscribe(() => { this.fixedDepositsService
this.router.navigate(['../../'], { relativeTo: this.route }); .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']) { @if (actions['Add Charge']) {
<mifosx-add-charge-fixed-deposits-account></mifosx-add-charge-fixed-deposits-account> <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']) { @if (actions['Close']) {
<mifosx-close-fixed-deposits-account></mifosx-close-fixed-deposits-account> <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 { 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 { 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 { 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 { CloseFixedDepositsAccountComponent } from './close-fixed-deposits-account/close-fixed-deposits-account.component';
import { FixedDepositsCashTransactionComponent } from './fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component'; import { FixedDepositsCashTransactionComponent } from './fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -35,7 +34,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
UndoApprovalFixedDepositsAccountComponent, UndoApprovalFixedDepositsAccountComponent,
WithdrawByClientFixedDepositsAccountComponent, WithdrawByClientFixedDepositsAccountComponent,
AddChargeFixedDepositsAccountComponent, AddChargeFixedDepositsAccountComponent,
PrematureCloseFixedDepositsAccountComponent,
CloseFixedDepositsAccountComponent, CloseFixedDepositsAccountComponent,
FixedDepositsCashTransactionComponent FixedDepositsCashTransactionComponent
] ]
@@ -52,7 +50,6 @@ export class FixedDepositsAccountActionsComponent {
'Undo Approval': boolean; 'Undo Approval': boolean;
'Undo Activation': boolean; 'Undo Activation': boolean;
'Add Charge': boolean; 'Add Charge': boolean;
'Premature Close': boolean;
'Withdrawn by Client': boolean; 'Withdrawn by Client': boolean;
Withdrawal: boolean; Withdrawal: boolean;
Deposit: boolean; Deposit: boolean;
@@ -64,7 +61,6 @@ export class FixedDepositsAccountActionsComponent {
'Undo Approval': false, 'Undo Approval': false,
'Undo Activation': false, 'Undo Activation': false,
'Add Charge': false, 'Add Charge': false,
'Premature Close': false,
'Withdrawn by Client': false, 'Withdrawn by Client': false,
Withdrawal: false, Withdrawal: false,
Deposit: 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 { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */ /** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service'; import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class RejectFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */ /** Minimum date allowed. */
minDate = new Date(2000, 0, 1); minDate = new Date(2000, 0, 1);
@@ -97,8 +99,19 @@ export class RejectFixedDepositsAccountComponent implements OnInit {
dateFormat, dateFormat,
locale locale
}; };
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'reject', data).subscribe(() => { this.fixedDepositsService
this.router.navigate(['../../'], { relativeTo: this.route }); .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'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */ /** Custom Services */
import { AlertService } from 'app/core/alert/alert.service';
import { SavingsService } from 'app/savings/savings.service'; import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from '../../fixed-deposits.service'; import { FixedDepositsService } from '../../fixed-deposits.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { CdkTextareaAutosize } from '@angular/cdk/text-field';
@@ -35,6 +36,7 @@ export class UndoApprovalFixedDepositsAccountComponent implements OnInit {
private fixedDepositsService = inject(FixedDepositsService); private fixedDepositsService = inject(FixedDepositsService);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private alertService = inject(AlertService);
/** Undo Approval Fixed Deposits Account form. */ /** Undo Approval Fixed Deposits Account form. */
undoApprovalFixedDepositsAccountForm: UntypedFormGroup; undoApprovalFixedDepositsAccountForm: UntypedFormGroup;
@@ -87,12 +89,30 @@ export class UndoApprovalFixedDepositsAccountComponent implements OnInit {
if (this.undoAction === 'Undo Activation') { if (this.undoAction === 'Undo Activation') {
this.fixedDepositsService this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, this.undoCommand, data) .executeFixedDepositsAccountCommand(this.accountId, this.undoCommand, data)
.subscribe(() => { .subscribe((response: any) => {
this.notifyIfSentForApproval(response);
this.router.navigate(['../../'], { relativeTo: this.route }); this.router.navigate(['../../'], { relativeTo: this.route });
}); });
} else { } else {
this.savingsService.executeSavingsAccountCommand(this.accountId, this.undoCommand, data).subscribe(() => { this.savingsService
this.router.navigate(['../../'], { relativeTo: this.route }); .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 { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Services */ /** Custom Services */
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service'; import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
@@ -37,6 +38,7 @@ export class WithdrawByClientFixedDepositsAccountComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private alertService = inject(AlertService);
/** Minimum date allowed. */ /** Minimum date allowed. */
minDate = new Date(2000, 0, 1); minDate = new Date(2000, 0, 1);
@@ -99,7 +101,16 @@ export class WithdrawByClientFixedDepositsAccountComponent implements OnInit {
}; };
this.fixedDepositsService this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'withdrawnByApplicant', data) .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 }); 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 { 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 { 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 { 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 { 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 { 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'; 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, WaiveChargeDialogComponent,
InactivateChargeDialogComponent, InactivateChargeDialogComponent,
AddChargeFixedDepositsAccountComponent, AddChargeFixedDepositsAccountComponent,
PrematureCloseFixedDepositsAccountComponent,
CloseFixedDepositsAccountComponent, CloseFixedDepositsAccountComponent,
CreateFixedDepositAccountComponent, CreateFixedDepositAccountComponent,
FixedDepositAccountDetailsStepComponent, FixedDepositAccountDetailsStepComponent,
@@ -133,7 +133,8 @@ export class FixedDepositsService {
calculateFixedDepositInterest(params: { calculateFixedDepositInterest(params: {
principalAmount: number; principalAmount: number;
annualInterestRate: number; annualInterestRate: number;
tenureInMonths: number; tenureInMonths?: number;
tenureInDays?: number; // alternative to tenureInMonths, for sub-month FD Single tenures
interestCompoundingPeriodInMonths: number; interestCompoundingPeriodInMonths: number;
interestPostingPeriodInMonths: number; interestPostingPeriodInMonths: number;
submittedOnDate?: string; // yyyy-MM-dd — enables exact calendar calculation 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 }); 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 * @param fixedDepositAccount Fixed Deposit Account
* @returns {Observable<any>} * @returns {Observable<any>}
@@ -181,15 +181,15 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (r of visibleRows; track r.accountId) { @for (r of visibleRows; track r.accountType + '-' + r.accountId) {
<tr [class.dr-row--warn]="isNearingEscheat(r)" [class.dr-row--sel]="selected.has(r.accountId)"> <tr [class.dr-row--warn]="isNearingEscheat(r)" [class.dr-row--sel]="isSelected(r)">
@if (tab === 'ESCHEAT') { @if (tab === 'ESCHEAT') {
<td class="dr-tick"> <td class="dr-tick">
<!-- An already-remitted balance cannot be remitted again. --> <!-- An already-remitted balance cannot be remitted again. -->
<input <input
type="checkbox" type="checkbox"
[checked]="selected.has(r.accountId)" [checked]="isSelected(r)"
[disabled]="r.remittance === 'REMITTED'" [disabled]="!isRemittable(r)"
(click)="toggleSelect(r, $event)" (click)="toggleSelect(r, $event)"
/> />
</td> </td>
@@ -203,7 +203,12 @@
<td>{{ branchName(r.branchCode) }}</td> <td>{{ branchName(r.branchCode) }}</td>
<td class="dr-prod">{{ r.productName }}</td> <td class="dr-prod">
{{ r.productName }}
@if (r.accountType === 'CURRENT') {
<div class="dr-sub">Current Account</div>
}
</td>
<td class="num strong">{{ r.balance | number: '1.0-0' }}</td> <td class="num strong">{{ r.balance | number: '1.0-0' }}</td>
@@ -147,8 +147,24 @@ export class DormancyReportComponent implements OnInit {
remitSaving = false; remitSaving = false;
remitError = ''; remitError = '';
/**
* Only savings accounts can be put into a DEAF remittance: remittances are recorded against a savings account, so
* the selection (keyed by account id) holds savings ids alone and a current account with the same id cannot be
* mistaken for one.
*/
isRemittable(row: DormancyRow): boolean {
return row.accountType !== 'CURRENT' && row.remittance !== 'REMITTED';
}
isSelected(row: DormancyRow): boolean {
return row.accountType !== 'CURRENT' && this.selected.has(row.accountId);
}
toggleSelect(row: DormancyRow, event: Event): void { toggleSelect(row: DormancyRow, event: Event): void {
event.stopPropagation(); event.stopPropagation();
if (!this.isRemittable(row)) {
return;
}
if (this.selected.has(row.accountId)) { if (this.selected.has(row.accountId)) {
this.selected.delete(row.accountId); this.selected.delete(row.accountId);
} else { } else {
@@ -158,7 +174,7 @@ export class DormancyReportComponent implements OnInit {
/** Ticks every unremitted row on display, or clears them if all are already ticked. */ /** Ticks every unremitted row on display, or clears them if all are already ticked. */
toggleSelectAll(): void { toggleSelectAll(): void {
const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED'); const selectable = this.visibleRows.filter((r) => this.isRemittable(r));
if (selectable.every((r) => this.selected.has(r.accountId))) { if (selectable.every((r) => this.selected.has(r.accountId))) {
selectable.forEach((r) => this.selected.delete(r.accountId)); selectable.forEach((r) => this.selected.delete(r.accountId));
} else { } else {
@@ -177,12 +193,12 @@ export class DormancyReportComponent implements OnInit {
} }
get allSelected(): boolean { get allSelected(): boolean {
const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED'); const selectable = this.visibleRows.filter((r) => this.isRemittable(r));
return selectable.length > 0 && selectable.every((r) => this.selected.has(r.accountId)); return selectable.length > 0 && selectable.every((r) => this.selected.has(r.accountId));
} }
get selectedTotal(): number { get selectedTotal(): number {
return this.rows.filter((r) => this.selected.has(r.accountId)).reduce((s, r) => s + r.balance, 0); return this.rows.filter((r) => this.isSelected(r)).reduce((s, r) => s + r.balance, 0);
} }
openRemit(): void { openRemit(): void {
@@ -316,6 +332,7 @@ export class DormancyReportComponent implements OnInit {
const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' }; const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' };
return { return {
accountType: a?.accountType || 'SAVINGS',
accountId: a?.accountId, accountId: a?.accountId,
accountNo: a?.accountNo || '—', accountNo: a?.accountNo || '—',
clientId: a?.clientId, clientId: a?.clientId,
@@ -80,6 +80,8 @@ export const DORMANCY_STAGES: Record<DormancyStage, DormancyStageMeta> = {
export type DeafRemittanceState = 'PENDING' | 'REMITTED'; export type DeafRemittanceState = 'PENDING' | 'REMITTED';
export interface DormancyRow { export interface DormancyRow {
/** SAVINGS or CURRENT. Ids are only unique within one kind, so rows are keyed by both. */
accountType: string;
accountId: number; accountId: number;
accountNo: string; accountNo: string;
clientId: number; clientId: number;
@@ -0,0 +1,29 @@
/**
* 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/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { FdDashboardComponent } from './fd-dashboard.component';
const routes: Routes = [
Route.withShell([
{
path: 'fd-dashboard',
component: FdDashboardComponent,
data: { title: 'FD Dashboard', breadcrumb: 'FD Dashboard' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FdDashboardRoutingModule {}
@@ -0,0 +1,240 @@
<!--
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="fdd-container">
<!-- Compact Light Header -->
<div class="fdd-header">
<div class="fdd-header__left">
<div class="fdd-header__icon">
<fa-icon icon="piggy-bank"></fa-icon>
</div>
<div>
<div class="fdd-header__title-row">
<h2 class="fdd-header__title">FD Dashboard & Maturity Ladder</h2>
<span class="fdd-header__badge">Core Banking</span>
</div>
<p class="fdd-header__sub">
Active Fixed Deposits positioned against maturity dates. Press <kbd>ESC</kbd> to toggle header & footer.
</p>
</div>
</div>
<div class="fdd-header__right">
<button class="fdd-btn fdd-btn--slim" (click)="load()" [disabled]="loading" title="Refresh maturity ladder data">
<fa-icon icon="sync-alt" [class.fa-spin]="loading"></fa-icon>
<span>{{ loading ? 'Syncing...' : 'Reload' }}</span>
</button>
</div>
</div>
<!-- Alerts -->
@if (errorMessage) {
<div class="fdd-alert fdd-alert--danger">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ errorMessage }}</span>
</div>
}
<!-- Compact Stat Cards Grid -->
<div class="fdd-stats-grid">
@for (key of stageKeys; track key) {
<button
[class]="'fdd-stat-card fdd-stat-card--' + stageMeta[key].tone"
[class.fdd-stat-card--active]="stageFilter === key"
(click)="toggleStage(key)"
>
<div class="fdd-stat-card__header">
<span class="fdd-stat-card__label">{{ stageMeta[key].label }}</span>
<div class="fdd-stat-card__icon">
@switch (key) {
@case ('MATURED') {
<fa-icon icon="exclamation-circle"></fa-icon>
}
@case ('DUE_SOON') {
<fa-icon icon="clock"></fa-icon>
}
@case ('UPCOMING') {
<fa-icon icon="calendar-alt"></fa-icon>
}
@default {
<fa-icon icon="check-circle"></fa-icon>
}
}
</div>
</div>
<div class="fdd-stat-card__val">
@switch (key) {
@case ('MATURED') {
{{ summary.matured }}
}
@case ('DUE_SOON') {
{{ summary.dueSoon }}
}
@case ('UPCOMING') {
{{ summary.upcoming }}
}
@default {
{{ summary.onTrack }}
}
}
</div>
<div class="fdd-stat-card__sub">{{ stageMeta[key].summary }}</div>
</button>
}
</div>
<!-- Compact Filter & Search Toolbar -->
<div class="fdd-toolbar">
<div class="fdd-search-box">
<fa-icon icon="search" class="fdd-search-box__icon"></fa-icon>
<input
class="fdd-search-box__input"
type="text"
placeholder="Search account no. or client name..."
[(ngModel)]="search"
/>
@if (search) {
<button class="fdd-search-box__clear" (click)="search = ''" title="Clear search">
<fa-icon icon="times"></fa-icon>
</button>
}
</div>
@if (stageFilter || search) {
<button class="fdd-btn fdd-btn--reset fdd-btn--slim" (click)="resetFilters()">
<fa-icon icon="undo"></fa-icon> Reset
</button>
}
<div class="fdd-toolbar__spacer"></div>
<div class="fdd-count-chip">
<fa-icon icon="layer-group"></fa-icon>
<span
><strong>{{ visibleRows.length }}</strong> of {{ summary.total }} active FDs</span
>
</div>
</div>
<!-- Worklist Table / Empty State -->
@if (loading) {
<div class="fdd-card fdd-state-card">
<fa-icon icon="spinner" [class.fa-spin]="true" class="fdd-spinner"></fa-icon>
<p>Loading FD maturity list...</p>
</div>
} @else if (visibleRows.length === 0) {
<div class="fdd-card fdd-state-card">
<fa-icon icon="inbox" class="fdd-state-icon"></fa-icon>
<p class="fdd-state-msg">
@if (!stageFilter && summary.total > 0) {
No FDs are matured or maturing within {{ policy.upcomingDays }} days. Click "On Track" to see all active FDs.
} @else {
No FDs match these filters.
}
</p>
@if (stageFilter || search) {
<button class="fdd-btn fdd-btn--primary fdd-btn--slim" (click)="resetFilters()">
<fa-icon icon="undo"></fa-icon> Clear Filters
</button>
}
</div>
} @else {
<div class="fdd-table-card">
<div class="fdd-table-wrapper">
<table class="fdd-table">
<thead>
<tr>
<th>Account</th>
<th>Client</th>
<th>Product</th>
<th>Maturity Date</th>
<th class="text-right">Deposit Amount</th>
<th class="text-right">Maturity Amount</th>
<th>Status</th>
<th class="text-center">Action</th>
</tr>
</thead>
<tbody>
@for (r of visibleRows; track r.id) {
<tr class="fdd-row" (click)="openAccount(r)">
<td>
<span class="fdd-acc-num">{{ r.accountNo || '—' }}</span>
</td>
<td>
<div class="fdd-client-cell">
<span class="fdd-client-name">{{ r.clientName }}</span>
</div>
</td>
<td>
<span class="fdd-product-tag">{{ r.productName }}</span>
</td>
<td>
<div class="fdd-date-cell">
<span class="fdd-date-val">{{ r.maturityDate }}</span>
<span class="fdd-date-sub" [class.fdd-date-sub--overdue]="r.daysOverdue > 0">
{{ maturityText(r) }}
</span>
</div>
</td>
<td class="text-right">
<span class="fdd-amount fdd-amount--deposit">
{{ r.depositAmount != null ? '₹' + (r.depositAmount | number: '1.2-2') : '—' }}
</span>
</td>
<td class="text-right">
<span class="fdd-amount fdd-amount--maturity">
{{ r.maturityAmount != null ? '₹' + (r.maturityAmount | number: '1.2-2') : '—' }}
</span>
</td>
<td>
<div class="fdd-status-cell">
<span [class]="stageClass(r.stage)">
{{ r.meta.label }}
</span>
@if (r.renewable) {
<div
class="fdd-grace-sub"
[class.fdd-grace-sub--warning]="r.renewalGracePeriodDays > 0 && !r.withinGraceWindow"
>
{{ graceStatusText(r) }}
</div>
} @else if (r.configuredInstructionLabel) {
<div class="fdd-grace-sub">Configured: {{ r.configuredInstructionLabel }} (auto-processes)</div>
}
</div>
</td>
<td class="fdd-act-cell text-center" (click)="$event.stopPropagation()">
@if (r.renewable) {
<button class="fdd-btn fdd-btn--action" [disabled]="renewing === r.id" (click)="openRenewDialog(r)">
@if (renewing === r.id) {
<fa-icon icon="spinner" [class.fa-spin]="true"></fa-icon> Renewing...
} @else {
<fa-icon icon="sync-alt"></fa-icon> Renew
}
</button>
} @else {
<button class="fdd-btn-icon" (click)="openAccount(r)" title="View Account Details">
<fa-icon icon="chevron-right"></fa-icon>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
@@ -0,0 +1,578 @@
/**
* 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/.
*/
/**
* Ultra-Compact Light FD Dashboard & Maturity Ladder Styles
* CBSNexora Core Banking Design System
*/
:host {
display: block;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
color: #0f172a;
background: #f8fafc;
min-height: 100vh;
padding: 12px 16px;
box-sizing: border-box;
}
.fdd-container {
max-width: 1400px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ── Light Compact Header ────────────────────────────────────────────────── */
.fdd-header {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: #eff6ff;
color: #2563eb;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
&__title-row {
display: flex;
align-items: center;
gap: 8px;
}
&__title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
&__badge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 6px;
border-radius: 4px;
background: #e0f2fe;
color: #0369a1;
}
&__sub {
margin: 2px 0 0;
font-size: 11.5px;
color: #64748b;
kbd {
background: #f1f5f9;
border: 1px solid #cbd5e1;
border-radius: 3px;
padding: 1px 4px;
font-family: inherit;
font-size: 10.5px;
font-weight: 700;
color: #334155;
}
}
}
/* ── Alerts ─────────────────────────────────────────────────────────────── */
.fdd-alert {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
&--danger {
background: #fef2f2;
border: 1px solid #fca5a5;
color: #dc2626;
}
&--warning {
background: #fffbeb;
border: 1px solid #fcd34d;
color: #d97706;
}
}
/* ── Compact Stat Cards Grid ────────────────────────────────────────────── */
.fdd-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.fdd-stat-card {
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #cbd5e1;
border-radius: 10px;
padding: 10px 14px;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 12px rgb(15 23 42 / 6%);
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
&__label {
font-size: 12px;
font-weight: 700;
color: #334155;
}
&__icon {
font-size: 14px;
}
&__val {
font-size: 20px;
font-weight: 800;
line-height: 1.1;
color: #0f172a;
margin-bottom: 2px;
}
&__sub {
font-size: 10.5px;
color: #64748b;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Tone styling */
&--danger {
border-left-color: #ef4444;
.fdd-stat-card__icon {
color: #ef4444;
}
&.fdd-stat-card--active {
background: #fef2f2;
border-color: #fca5a5;
}
}
&--warn {
border-left-color: #f59e0b;
.fdd-stat-card__icon {
color: #f59e0b;
}
&.fdd-stat-card--active {
background: #fffbeb;
border-color: #fcd34d;
}
}
&--info {
border-left-color: #3b82f6;
.fdd-stat-card__icon {
color: #3b82f6;
}
&.fdd-stat-card--active {
background: #eff6ff;
border-color: #93c5fd;
}
}
&--neutral {
border-left-color: #64748b;
.fdd-stat-card__icon {
color: #64748b;
}
&.fdd-stat-card--active {
background: #f1f5f9;
border-color: #cbd5e1;
}
}
}
/* ── Compact Filter Toolbar ─────────────────────────────────────────────── */
.fdd-toolbar {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 6px 10px;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 1px 2px rgb(0 0 0 / 2%);
&__spacer {
flex: 1;
}
}
.fdd-search-box {
position: relative;
display: flex;
align-items: center;
width: 300px;
&__icon {
position: absolute;
left: 10px;
color: #94a3b8;
font-size: 12px;
pointer-events: none;
}
&__input {
width: 100%;
padding: 5px 30px 5px 28px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
color: #0f172a;
background: #fff;
&:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 2px rgb(37 99 235 / 15%);
}
}
&__clear {
position: absolute;
right: 6px;
border: none;
background: transparent;
color: #94a3b8;
cursor: pointer;
padding: 2px 4px;
&:hover {
color: #334155;
}
}
}
.fdd-count-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: #64748b;
}
/* ── Buttons ────────────────────────────────────────────────────────────── */
.fdd-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: 1px solid #cbd5e1;
background: #fff;
color: #334155;
transition: all 0.15s ease;
&:hover:not(:disabled) {
border-color: #2563eb;
color: #2563eb;
background: #f8fafc;
}
&--slim {
padding: 4px 10px;
font-size: 11.5px;
}
&--primary {
background: #2563eb;
color: #fff;
border-color: #2563eb;
&:hover:not(:disabled) {
background: #1d4ed8;
color: #fff;
}
}
&--action {
background: #2563eb;
color: #fff;
border-color: #2563eb;
padding: 4px 10px;
font-size: 11px;
border-radius: 5px;
&:hover:not(:disabled) {
background: #1d4ed8;
color: #fff;
}
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.fdd-btn-icon {
width: 26px;
height: 26px;
border-radius: 5px;
border: 1px solid #e2e8f0;
background: #fff;
color: #64748b;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: #eff6ff;
color: #2563eb;
border-color: #93c5fd;
}
}
/* ── Compact Table ──────────────────────────────────────────────────────── */
.fdd-table-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 3px rgb(0 0 0 / 3%);
}
.fdd-table-wrapper {
overflow-x: auto;
}
.fdd-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 12px;
th {
padding: 8px 10px;
background: #f1f5f9;
color: #475569;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
td {
padding: 7px 10px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
}
.fdd-row {
cursor: pointer;
&:hover {
background: #f8fafc;
}
&:last-child td {
border-bottom: none;
}
}
.fdd-acc-num {
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
font-weight: 600;
color: #0f172a;
}
.fdd-client-name {
font-weight: 600;
color: #0f172a;
}
.fdd-product-tag {
font-size: 11px;
color: #475569;
}
.fdd-date-cell {
display: flex;
flex-direction: column;
gap: 1px;
.fdd-date-val {
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
color: #0f172a;
}
.fdd-date-sub {
font-size: 10.5px;
color: #64748b;
&--overdue {
color: #dc2626;
font-weight: 700;
}
}
}
.fdd-amount {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
&--deposit {
color: #334155;
}
&--maturity {
color: #2563eb;
font-weight: 700;
}
}
.fdd-status-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.fdd-stage {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 10.5px;
font-weight: 700;
width: fit-content;
&--info {
background: #eff6ff;
color: #2563eb;
}
&--warn {
background: #fffbeb;
color: #d97706;
}
&--danger {
background: #fef2f2;
color: #dc2626;
}
&--neutral {
background: #f1f5f9;
color: #475569;
}
}
.fdd-grace-sub {
font-size: 10px;
color: #64748b;
&--warning {
color: #dc2626;
font-weight: 600;
}
}
.fdd-card.fdd-state-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 24px;
text-align: center;
font-size: 12px;
color: #64748b;
.fdd-state-icon {
font-size: 24px;
color: #94a3b8;
margin-bottom: 8px;
}
.fdd-spinner {
font-size: 20px;
color: #2563eb;
margin-bottom: 8px;
}
.fdd-state-msg {
margin: 0 0 12px;
}
}
@@ -0,0 +1,273 @@
/**
* 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 { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
/** Custom Modules */
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Services */
import { AccountsService } from '../accounts/accounts.service';
import { FixedDepositsService } from '../deposits/fixed-deposits/fixed-deposits.service';
import { toIsoDate } from 'app/core/utils/api-date';
/** Dashboard data + computation */
import {
FD_MATURITY_POLICY,
FD_MATURITY_STAGES,
FdMaturityRow,
FdMaturityStage,
FdMaturitySummary,
buildMaturityRow,
maturitySummary,
sortMaturityRows
} from './fd-dashboard.compute';
import { PaymentDialogComponent } from '../deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component';
/**
* FD Dashboard: every active Fixed Deposit positioned against its own maturity date.
*
* Read-only and computed, the same way the re-KYC expiry worklist is: the ladder (Matured / Due
* Soon / Upcoming / On Track) is worked out here from `maturityDate`, not stored anywhere, so this
* screen can never drift out of step with the account -- it always says what today's date implies.
* Data comes from the same `/fixeddepositaccounts` list Accounts > Fixed Deposit already uses;
* nothing new was added on the backend for this.
*/
@Component({
selector: 'mifosx-fd-dashboard',
templateUrl: './fd-dashboard.component.html',
styleUrls: ['./fd-dashboard.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class FdDashboardComponent implements OnInit {
private accountsService = inject(AccountsService);
private fixedDepositsService = inject(FixedDepositsService);
private router = inject(Router);
private dialog = inject(MatDialog);
loading = false;
errorMessage = '';
rows: FdMaturityRow[] = [];
/** Renew action state -- the dialog itself owns submit-in-progress/error display; this just guards
* against opening a second dialog for the same row while one is already up. */
renewing: number | null = null;
/** Filters */
stageFilter: FdMaturityStage | '' = '';
search = '';
readonly policy = FD_MATURITY_POLICY;
readonly stageMeta = FD_MATURITY_STAGES;
/** ON_TRACK is reachable only by clicking its own tile -- the default view stays on what needs
* attention (matured or maturing soon), matching why this screen exists. */
readonly stageKeys: FdMaturityStage[] = [
'MATURED',
'DUE_SOON',
'UPCOMING',
'ON_TRACK'
];
ngOnInit(): void {
this.load();
}
/**
* Loads every active FD and re-derives the maturity ladder for each.
*
* Calls the dedicated maturity-dashboard endpoint (backend: FixedDepositMaturityDashboardApiResource),
* not the general Accounts > Fixed Deposit list endpoint -- that one's query is wide (six extra
* table joins for audit/currency/tax fields this screen never shows) and, until this feature
* added them, ran over unindexed columns. The dashboard endpoint already filters to ACTIVE FDs
* (deposit_type_enum/status_enum are fixed in its SQL, not a request parameter), so there's
* nothing to re-check client-side here.
*/
load(): void {
this.loading = true;
this.errorMessage = '';
const today = toIsoDate(new Date().toISOString());
this.accountsService.getFixedDepositMaturityDashboard().subscribe({
next: (res: any) => {
const items: any[] = Array.isArray(res) ? res : [];
const mapped = items
.map((a: any) => this.mapAccount(a))
.map((a) => buildMaturityRow(a, today))
.filter((r): r is FdMaturityRow => !!r);
this.rows = sortMaturityRows(mapped);
this.loading = false;
},
error: (err: any) => {
this.rows = [];
this.loading = false;
this.errorMessage =
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the FD maturity list.';
}
});
}
/** Maps one raw `/fixeddepositaccounts/maturity-dashboard` list item onto what the ladder needs. */
private mapAccount(a: any): {
id: number;
accountNo: string;
clientId?: number;
clientName?: string;
productName?: string;
savingsProductName?: string;
fdType?: string;
maturityDate: string;
depositAmount?: number;
maturityAmount?: number;
onClosureType?: number;
linkedAccountId?: number;
renewalGracePeriodDays?: number;
} {
return {
id: a?.id,
accountNo: a?.accountNo || '',
clientId: a?.clientId,
clientName: a?.clientName,
productName: a?.productName,
savingsProductName: a?.savingsProductName,
fdType: a?.fdType,
maturityDate: toIsoDate(a?.maturityDate),
depositAmount: a?.depositAmount,
maturityAmount: a?.maturityAmount,
onClosureType: a?.onClosureType,
linkedAccountId: a?.linkedAccountId,
renewalGracePeriodDays: a?.renewalGracePeriodDays
};
}
// ── Filtering ──────────────────────────────────────────────────────────────
get visibleRows(): FdMaturityRow[] {
const needle = this.search.trim().toLowerCase();
const stage = this.stageFilter;
return this.rows.filter((r) => {
if (stage) {
if (r.stage !== stage) {
return false;
}
} else if (r.stage === 'ON_TRACK') {
// No tile selected -- the default view is the worklist, not every active FD.
return false;
}
if (needle) {
const hay = `${r.accountNo} ${r.clientName}`.toLowerCase();
if (!hay.includes(needle)) {
return false;
}
}
return true;
});
}
get summary(): FdMaturitySummary {
return maturitySummary(this.rows);
}
resetFilters(): void {
this.stageFilter = '';
this.search = '';
}
toggleStage(stage: FdMaturityStage): void {
this.stageFilter = this.stageFilter === stage ? '' : stage;
}
// ── Navigation ─────────────────────────────────────────────────────────────
openAccount(row: FdMaturityRow): void {
if (!row.clientId) {
return;
}
this.router.navigate([
'/clients',
row.clientId,
'fixed-deposits-accounts',
row.id,
'general'
]);
}
// ── Renewal ────────────────────────────────────────────────────────────────
/** Renew is a single action on a matured, renewable row -- opens the same Payment dialog an account
* page's own Account Actions menu uses, scoped to just the Reinvest choices, with the closure date
* and gap-interest crediting decided from the grace window rather than picked here (see
* PaymentDialogComponent's `dashboardRenewal` mode). */
openRenewDialog(row: FdMaturityRow): void {
if (!row.renewable || this.renewing != null) {
return;
}
this.renewing = row.id;
this.dialog
.open(PaymentDialogComponent, {
width: '720px',
maxWidth: '95vw',
data: {
accountId: row.id,
accountBalance: row.maturityAmount,
maturityDate: row.maturityDate,
dashboardRenewal: { row }
}
})
.afterClosed()
.subscribe((result: { confirm: boolean } | undefined) => {
this.renewing = null;
if (result?.confirm) {
this.load();
}
});
}
// ── Presentation ───────────────────────────────────────────────────────────
stageClass(stage: FdMaturityStage): string {
return `fdd-stage fdd-stage--${FD_MATURITY_STAGES[stage].tone}`;
}
/** "12 days overdue" / "in 5 days" / "Matures today", as a branch officer would say it. */
maturityText(row: FdMaturityRow): string {
if (row.daysOverdue > 0) {
return `${row.daysOverdue} day${row.daysOverdue === 1 ? '' : 's'} overdue`;
}
if (row.daysRemaining === 0) {
return 'Matures today';
}
return `In ${row.daysRemaining} day${row.daysRemaining === 1 ? '' : 's'}`;
}
/** "Renewable — 6 of 9 grace days left" / "Grace period (9d) passed 3 days ago" / "Renewable" (no
* grace configured) -- the sub-label under a matured, renewable row's stage badge. */
graceStatusText(row: FdMaturityRow): string {
if (!row.renewalGracePeriodDays) {
return 'Renewable';
}
const grace = row.renewalGracePeriodDays;
if (row.withinGraceWindow) {
const daysLeft = grace - row.daysOverdue;
return `Renewable — ${daysLeft} of ${grace} grace day${grace === 1 ? '' : 's'} left`;
}
const daysPastGrace = row.daysOverdue - grace;
return `Grace period (${grace}d) passed ${daysPastGrace} day${daysPastGrace === 1 ? '' : 's'} ago`;
}
}
@@ -0,0 +1,240 @@
/**
* 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/.
*/
/**
* Where an active Fixed Deposit sits relative to its own maturity date.
*
* MATURED accounts are the ones staff actually need to act on -- the FD has run its course but is
* still ACTIVE in the core, meaning nobody has applied the maturity instruction yet (or the
* scheduled job hasn't reached it). DUE_SOON and UPCOMING exist so a branch can see what's coming
* before it lands, not just after.
*/
export type FdMaturityStage = 'MATURED' | 'DUE_SOON' | 'UPCOMING' | 'ON_TRACK';
/** Day-count thresholds, measured forward from today toward the maturity date. */
export const FD_MATURITY_POLICY = {
/** Maturing within this many days counts as DUE_SOON. */
dueSoonDays: 7,
/** Maturing within this many days (and not already DUE_SOON) counts as UPCOMING. */
upcomingDays: 30
};
export interface FdMaturityStageMeta {
stage: FdMaturityStage;
label: string;
/** Colour key the template maps to a badge/tile class. */
tone: 'info' | 'warn' | 'danger' | 'neutral';
summary: string;
}
export const FD_MATURITY_STAGES: Record<FdMaturityStage, FdMaturityStageMeta> = {
MATURED: {
stage: 'MATURED',
label: 'Matured',
tone: 'danger',
summary: 'Past maturity date and still active -- the maturity instruction has not been applied yet.'
},
DUE_SOON: {
stage: 'DUE_SOON',
label: `Maturing ≤ ${FD_MATURITY_POLICY.dueSoonDays} Days`,
tone: 'warn',
summary: 'Matures within the week.'
},
UPCOMING: {
stage: 'UPCOMING',
label: `Maturing ≤ ${FD_MATURITY_POLICY.upcomingDays} Days`,
tone: 'info',
summary: 'Matures within the month.'
},
ON_TRACK: {
stage: 'ON_TRACK',
label: 'On Track',
tone: 'neutral',
summary: 'Maturity date is further out.'
}
};
/** FD types the manual-renewal action applies to. SPECIAL is architecturally forced to Transfer to
* Savings server-side and can never be Reinvest-configured, so it's never renewable here. */
const RENEWABLE_FD_TYPES = [
'SINGLE',
'DOUBLE'
];
/** DepositAccountOnClosureType labels, for the "what will happen automatically" sub-label shown on a
* matured row that already carries an explicit instruction from account-opening. */
const ON_CLOSURE_TYPE_LABELS: Record<number, string> = {
200: 'Transfer to Savings',
300: 'Reinvest Principal + Interest',
400: 'Reinvest Principal Only'
};
/** One active Fixed Deposit account, positioned on the maturity ladder. */
export interface FdMaturityRow {
id: number;
accountNo: string;
clientId: number | null;
clientName: string;
productName: string;
maturityDate: string;
depositAmount: number | null;
maturityAmount: number | null;
/** Negative once the maturity date has passed. */
daysRemaining: number;
/** 0 while not yet matured; how many days past maturity otherwise. */
daysOverdue: number;
stage: FdMaturityStage;
meta: FdMaturityStageMeta;
/** Null/0 -- no buffer configured; renewing after the maturity date itself pays gap interest. */
renewalGracePeriodDays: number | null;
linkedAccountId: number | null;
/** The account's own stored DepositAccountOnClosureType, raw -- null when none was chosen at
* account-opening. On a renewable row that DOES carry one (grace-period case: the account picked
* Reinvest, and is only waiting because the product also has a grace window), the Renew dialog
* pre-selects this rather than presenting a neutral, undecided choice -- the maker already decided
* what this FD should do, grace only changed when that gets carried out. */
onClosureType: number | null;
/** True only when the account was opened with NO maturity instruction at all (the officer left
* Maturity Instructions blank at account-opening) -- that's the one case nothing else will ever
* act on this FD, so the Dashboard's manual Renew action is meaningful. An account that WAS given
* an explicit instruction (Transfer to Savings or either Reinvest option) auto-processes on its
* own via the nightly job, exactly as it always has -- the Dashboard only reports on those, it
* doesn't also offer to renew them. */
isReinvestEligible: boolean;
/** True once matured AND eligible -- the only case where nothing has auto-processed it, so a
* manual action from the Dashboard is meaningful. */
renewable: boolean;
/** Only meaningful when renewable -- true if still inside the grace window (backdated renewal,
* no gap interest), false if the window has passed (renews from today, gap interest applies). */
withinGraceWindow: boolean;
/** Set on a matured row that is NOT renewable because it already carries an explicit instruction --
* what it was configured to do at account-opening, shown so staff can see at a glance what tonight's
* job will do without opening the account. Null for a renewable row (nothing decided yet) or a row
* that isn't matured. */
configuredInstructionLabel: string | null;
}
/** Days between two ISO dates, positive when `later` is after `earlier`. */
function daysBetween(earlier: string, later: string): number {
const a = new Date(`${earlier}T00:00:00Z`).getTime();
const b = new Date(`${later}T00:00:00Z`).getTime();
return Math.round((b - a) / 86400000);
}
/** Which rung of the ladder an account is on, from how many days remain until maturity. */
export function maturityStage(daysRemaining: number): FdMaturityStage {
if (daysRemaining <= 0) {
return 'MATURED';
}
if (daysRemaining <= FD_MATURITY_POLICY.dueSoonDays) {
return 'DUE_SOON';
}
if (daysRemaining <= FD_MATURITY_POLICY.upcomingDays) {
return 'UPCOMING';
}
return 'ON_TRACK';
}
/** Builds one dashboard row from a raw `/fixeddepositaccounts` list item and today's ISO date. */
export function buildMaturityRow(
fd: {
id: number;
accountNo: string;
clientId?: number;
clientName?: string;
savingsProductName?: string;
productName?: string;
fdType?: string;
maturityDate: string;
depositAmount?: number;
maturityAmount?: number;
onClosureType?: number;
linkedAccountId?: number;
renewalGracePeriodDays?: number;
},
today: string
): FdMaturityRow | null {
if (!fd.maturityDate) {
// No maturity date means the account isn't fully assembled yet (e.g. a chart not applied) --
// nothing sound to bucket it by, so it's left off the worklist rather than shown wrong.
return null;
}
const daysRemaining = daysBetween(today, fd.maturityDate);
const stage = maturityStage(daysRemaining);
// No grace configured reads as a 0-day buffer, not "not renewable" -- it only shrinks the backdate
// window, matching the backend's default (renewMaturedFixedDeposit treats a missing value as 0).
const gracePeriodDays = fd.renewalGracePeriodDays ?? 0;
// Mirrors updateMaturityDetails()'s own "will I auto-process this?" check exactly, inverted: the
// nightly job leaves an account alone (never touches it) only when it was opened with NO maturity
// instruction at all -- that's the one case the Dashboard's Renew action exists for. An account
// opened WITH an explicit instruction auto-processes tonight regardless of the product's renewal
// grace period (the grace window never delays it), so it's never renewable here either way.
const isReinvestEligible =
!!fd.fdType && RENEWABLE_FD_TYPES.includes(fd.fdType as string) && fd.onClosureType == null;
const renewable = stage === 'MATURED' && isReinvestEligible;
const withinGraceWindow = renewable && daysOverdueFor(daysRemaining) <= gracePeriodDays;
const configuredInstructionLabel =
stage === 'MATURED' && !renewable && fd.onClosureType != null
? (ON_CLOSURE_TYPE_LABELS[fd.onClosureType] ?? null)
: null;
return {
id: fd.id,
accountNo: fd.accountNo || '',
clientId: fd.clientId ?? null,
clientName: fd.clientName || '—',
productName: fd.productName || fd.savingsProductName || '—',
maturityDate: fd.maturityDate,
depositAmount: fd.depositAmount ?? null,
maturityAmount: fd.maturityAmount ?? null,
daysRemaining,
daysOverdue: daysOverdueFor(daysRemaining),
stage,
meta: FD_MATURITY_STAGES[stage],
renewalGracePeriodDays: gracePeriodDays,
linkedAccountId: fd.linkedAccountId ?? null,
onClosureType: fd.onClosureType ?? null,
isReinvestEligible,
renewable,
withinGraceWindow,
configuredInstructionLabel
};
}
function daysOverdueFor(daysRemaining: number): number {
return daysRemaining < 0 ? -daysRemaining : 0;
}
export interface FdMaturitySummary {
total: number;
matured: number;
dueSoon: number;
upcoming: number;
onTrack: number;
}
export function maturitySummary(rows: FdMaturityRow[]): FdMaturitySummary {
return {
total: rows.length,
matured: rows.filter((r) => r.stage === 'MATURED').length,
dueSoon: rows.filter((r) => r.stage === 'DUE_SOON').length,
upcoming: rows.filter((r) => r.stage === 'UPCOMING').length,
onTrack: rows.filter((r) => r.stage === 'ON_TRACK').length
};
}
/** Sorts the worklist so the most overdue, then the soonest-to-mature, rows come first. */
export function sortMaturityRows(rows: FdMaturityRow[]): FdMaturityRow[] {
const rank: Record<FdMaturityStage, number> = {
MATURED: 0,
DUE_SOON: 1,
UPCOMING: 2,
ON_TRACK: 3
};
return [...rows].sort((a, b) => rank[a.stage] - rank[b.stage] || a.daysRemaining - b.daysRemaining);
}
@@ -0,0 +1,22 @@
/**
* 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/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { FdDashboardRoutingModule } from './fd-dashboard-routing.module';
import { FdDashboardComponent } from './fd-dashboard.component';
@NgModule({
imports: [
SharedModule,
FdDashboardRoutingModule,
FdDashboardComponent
]
})
export class FdDashboardModule {}
@@ -0,0 +1,190 @@
<!--
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="ipr">
<div class="ipr-head">
<div>
<h2 class="ipr-head-t"><fa-icon icon="percentage"></fa-icon> Interest Posting Reports</h2>
<div class="ipr-head-s">
Each branch gets its own report for each savings product, generated automatically at 12:00 PM on the posting
date and open to generating or regenerating at any time. The branch approves it, and its approved interest is
posted when the branch closes the day — it cannot close until then. The head office closes last.
</div>
</div>
<div class="ipr-head-a">
<label class="ipr-date">
Posting date
<input type="date" [(ngModel)]="postingDate" (change)="load()" />
</label>
<button class="ipr-btn" (click)="load()" [disabled]="loading"><fa-icon icon="undo"></fa-icon> Reload</button>
</div>
</div>
@if (errorMessage) {
<div class="ipr-alert ipr-alert--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ errorMessage }}</div>
</div>
}
@if (notice) {
<div class="ipr-alert ipr-alert--ok">
<fa-icon icon="check"></fa-icon>
<div>{{ notice }}</div>
</div>
}
@if (pendingCount > 0) {
<div class="ipr-alert ipr-alert--warn">
<fa-icon icon="clock"></fa-icon>
<div>{{ pendingCount }} report(s) waiting for approval. The day cannot be closed until they are approved.</div>
</div>
}
@if (due.length > 0) {
<div class="ipr-alert ipr-alert--warn ipr-due">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div class="ipr-due-body">
<div>Interest is due but no report has been generated yet for:</div>
@for (d of due; track d.productId + '-' + d.officeId) {
<div class="ipr-due-row">
<strong>{{ d.productName }}</strong> <span>· {{ d.officeName }} · {{ d.postingDate }}</span>
<button class="ipr-btn ipr-btn--primary ipr-btn--slim" (click)="generate(d)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon> Generate report
</button>
</div>
}
</div>
</div>
}
<div class="ipr-card">
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Posting date</th>
<th class="num">Accounts</th>
<th class="num">Total interest</th>
<th>Status</th>
<th>Generated</th>
<th>Approved</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of reports; track r.id) {
<tr [class.is-selected]="selected?.id === r.id" (click)="open(r)">
<td>{{ r.officeName }}</td>
<td>
<strong>{{ r.productName }}</strong>
</td>
<td>{{ r.postingDate }}</td>
<td class="num">{{ r.accountCount }}</td>
<td class="num">₹{{ r.totalInterest | number: '1.2-2' }}</td>
<td>
<span class="ipr-badge" [ngClass]="statusClass(r.status)">{{ r.status }}</span>
@if (r.failureReason) {
<div class="ipr-fail" [title]="r.failureReason">Posting failed: {{ r.failureReason }}</div>
}
</td>
<td class="muted">
{{ r.generatedBy || 'System' }} · {{ r.generationMode }}<br />{{ r.generatedAt | date: 'dd MMM, HH:mm' }}
</td>
<td class="muted">
@if (r.approvedAt) {
{{ r.approvedBy }}
@if (r.approvedOnBehalf) {
<span class="ipr-behalf">(HO)</span>
}
<br />{{ r.approvedAt | date: 'dd MMM, HH:mm' }}
} @else {
—
}
</td>
<td class="actions" (click)="$event.stopPropagation()">
@if (canRegenerate(r)) {
<button class="ipr-btn ipr-btn--slim" (click)="regenerate(r)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon> Regenerate
</button>
}
@if (canApprove(r)) {
<button class="ipr-btn ipr-btn--primary ipr-btn--slim" (click)="approve(r)" [disabled]="busy">
<fa-icon icon="check"></fa-icon> Approve
</button>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="ipr-empty">
@if (loading) {
Loading…
} @else {
No interest posting report for {{ postingDate || 'this date' }}. Reports are generated at 12:00 PM on a
product's posting date.
}
</td>
</tr>
}
</tbody>
</table>
</div>
@if (selected) {
<div class="ipr-card">
<div class="ipr-detail-head">
<div>
<h3>{{ selected.officeName }} · {{ selected.productName }} — {{ selected.postingDate }}</h3>
<div class="muted">
Dr {{ selected.expenseGlCode }} {{ selected.expenseGlName }} → Cr {{ selected.payableGlCode }}
{{ selected.payableGlName }}; Dr {{ selected.payableGlCode }} → Cr {{ selected.paidGlCode }}
{{ selected.paidGlName }}; then Dr {{ selected.paidGlCode }} → Cr each account below
</div>
</div>
<div class="ipr-total">
<span>Total interest</span>
<strong>₹{{ selected.totalInterest | number: '1.2-2' }}</strong>
<span class="muted">{{ selected.accountCount }} account(s)</span>
</div>
</div>
<table class="ipr-table">
<thead>
<tr>
<th>Account no</th>
<th>Account holder</th>
<th class="num">Interest</th>
<th class="num">Balance before posting</th>
<th class="num">Balance after posting</th>
<th>Posted txn</th>
</tr>
</thead>
<tbody>
@for (l of selected.lines; track l.savingsAccountId) {
<tr>
<td>{{ l.accountNo }}</td>
<td>{{ l.clientName }}</td>
<td class="num">₹{{ l.interestAmount | number: '1.2-2' }}</td>
<td class="num">₹{{ l.balanceBefore | number: '1.2-2' }}</td>
<td class="num">₹{{ l.balanceAfter | number: '1.2-2' }}</td>
<td class="muted">{{ l.savingsTransactionId ? '#' + l.savingsTransactionId : '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="2"><strong>Total</strong></td>
<td class="num">
<strong>₹{{ selected.totalInterest | number: '1.2-2' }}</strong>
</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
}
</div>
@@ -0,0 +1,276 @@
/**
* 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/.
*/
:host {
--i-navy: #1a237e;
--i-blue: #1d4ed8;
--i-blue-l: #eff6ff;
--i-green: #15803d;
--i-green-l: #dcfce7;
--i-amber: #b45309;
--i-amber-l: #fef3c7;
--i-red: #c0392b;
--i-red-l: #fdecea;
--i-g100: #f1f5f9;
--i-g200: #e2e8f0;
--i-g500: #64748b;
--i-g900: #0f172a;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
color: var(--i-g900);
}
.ipr {
max-width: 1280px;
margin: 0 auto;
padding: 16px;
}
.ipr-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
margin-bottom: 14px;
}
.ipr-head-t {
margin: 0;
font-size: 22px;
color: var(--i-navy);
}
.ipr-head-s {
max-width: 720px;
margin-top: 4px;
font-size: 13px;
color: var(--i-g500);
}
.ipr-head-a {
display: flex;
align-items: flex-end;
gap: 8px;
}
.ipr-date {
display: flex;
flex-direction: column;
font-size: 11px;
color: var(--i-g500);
input,
select {
padding: 6px 8px;
border: 1px solid var(--i-g200);
border-radius: 6px;
font: inherit;
font-size: 13px;
}
}
.ipr-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border: 1px solid var(--i-g200);
border-radius: 6px;
background: #fff;
font: inherit;
font-size: 13px;
cursor: pointer;
&:disabled {
opacity: 0.55;
cursor: default;
}
}
.ipr-btn--primary {
border-color: var(--i-blue);
background: var(--i-blue);
color: #fff;
}
.ipr-btn--slim {
padding: 4px 10px;
font-size: 12px;
}
.ipr-alert {
display: flex;
gap: 8px;
margin-bottom: 10px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
}
.ipr-alert--error {
background: var(--i-red-l);
color: var(--i-red);
}
.ipr-alert--ok {
background: var(--i-green-l);
color: var(--i-green);
}
.ipr-alert--warn {
background: var(--i-amber-l);
color: var(--i-amber);
}
.ipr-card {
margin-bottom: 14px;
border: 1px solid var(--i-g200);
border-radius: 10px;
background: #fff;
overflow-x: auto;
}
.ipr-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 9px 12px;
border-bottom: 1px solid var(--i-g100);
text-align: left;
vertical-align: top;
}
th {
background: var(--i-g100);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--i-g500);
}
tbody tr {
cursor: pointer;
}
tbody tr.is-selected {
background: var(--i-blue-l);
}
.num {
text-align: right;
white-space: nowrap;
}
.actions {
display: flex;
gap: 6px;
justify-content: flex-end;
cursor: default;
}
tfoot td {
background: var(--i-g100);
}
}
.muted {
color: var(--i-g500);
font-size: 12px;
}
.ipr-empty {
padding: 24px !important;
text-align: center !important;
color: var(--i-g500);
}
.ipr-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.ipr-badge--pending {
background: var(--i-amber-l);
color: var(--i-amber);
}
.ipr-badge--approved {
background: var(--i-blue-l);
color: var(--i-blue);
}
.ipr-badge--posted {
background: var(--i-green-l);
color: var(--i-green);
}
.ipr-badge--muted {
background: var(--i-g100);
color: var(--i-g500);
}
.ipr-fail {
margin-top: 4px;
font-size: 11px;
color: var(--i-red);
}
.ipr-detail-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
h3 {
margin: 0 0 4px;
font-size: 16px;
color: var(--i-navy);
}
}
.ipr-total {
display: flex;
flex-direction: column;
align-items: flex-end;
strong {
font-size: 22px;
color: var(--i-navy);
}
}
.ipr-due-body {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.ipr-due-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
// Approved by the head office on behalf of a branch that did not open that day.
.ipr-behalf {
margin-left: 4px;
font-size: 11px;
font-weight: 600;
color: var(--i-amber);
}
@@ -0,0 +1,200 @@
/**
* 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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SettingsService } from 'app/settings/settings.service';
import {
InterestPostingDue,
InterestPostingReport,
InterestPostingReportDetail,
InterestPostingReportService
} from './interest-posting-report.service';
/**
* Savings interest posting reports.
*
* On a product's posting date the system generates, at noon, the list of what each savings account
* will be paid. It is reviewed and approved here; the approved amounts are posted when the last
* branch closes the day, and until then no branch can close. Regenerating replaces the report and
* needs approving again.
*/
@Component({
selector: 'mifosx-interest-posting-report',
templateUrl: './interest-posting-report.component.html',
styleUrls: ['./interest-posting-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class InterestPostingReportComponent implements OnInit {
private reportService = inject(InterestPostingReportService);
private settingsService = inject(SettingsService);
postingDate = '';
loading = false;
busy = false;
errorMessage = '';
notice = '';
reports: InterestPostingReport[] = [];
selected: InterestPostingReportDetail | null = null;
/** Products with interest due and no report yet — the noon job ran before they had anything due, or failed. */
due: InterestPostingDue[] = [];
ngOnInit(): void {
this.postingDate = this.businessDateIso();
this.load();
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.reportService.list(this.postingDate || undefined).subscribe({
next: (reports) => {
this.reports = reports ?? [];
this.loading = false;
const stillThere = this.selected && this.reports.find((r) => r.id === this.selected?.id);
if (stillThere) {
this.open(stillThere);
} else {
this.selected = null;
}
},
error: (err) => {
this.loading = false;
this.errorMessage = this.messageOf(err, 'Could not load the interest posting reports.');
}
});
this.reportService.dueWithoutReport(this.postingDate || undefined).subscribe({
next: (due) => (this.due = due ?? []),
error: () => (this.due = [])
});
}
generate(item: InterestPostingDue): void {
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.regenerate(item.productId, item.officeId, item.postingDate).subscribe({
next: (res) => {
this.busy = false;
this.notice = `Report generated for ${item.productName} — ${item.officeName}. Review and approve it.`;
this.load();
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not generate the report.');
}
});
}
open(report: InterestPostingReport): void {
this.errorMessage = '';
this.reportService.detail(report.id).subscribe({
next: (detail) => (this.selected = detail),
error: (err) => (this.errorMessage = this.messageOf(err, 'Could not load the report.'))
});
}
regenerate(report: InterestPostingReport): void {
if (
!confirm(
`Regenerate the interest posting report for ${report.productName} — ${report.officeName}? It will need approving again.`
)
) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.regenerate(report.productId, report.officeId, report.postingDate).subscribe({
next: (res) => {
this.busy = false;
this.notice = `Report regenerated for ${report.productName}. Review and approve it.`;
this.selected = null;
this.load();
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not regenerate the report.');
}
});
}
approve(report: InterestPostingReport): void {
if (
!confirm(
`Approve interest of ₹${report.totalInterest} across ${report.accountCount} account(s) for ${report.productName}? ` +
'It will be posted when the branch closes the day.'
)
) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.approve(report.id).subscribe({
next: () => {
this.busy = false;
this.notice = `Approved. Interest for ${report.productName} — ${report.officeName} will be posted when the branch closes the day.`;
this.load();
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not approve the report.');
}
});
}
canRegenerate(report: InterestPostingReport): boolean {
return report.canAct && (report.status === 'GENERATED' || report.status === 'APPROVED');
}
canApprove(report: InterestPostingReport): boolean {
return report.canAct && report.status === 'GENERATED';
}
statusClass(status: string): string {
return (
{
GENERATED: 'ipr-badge--pending',
APPROVED: 'ipr-badge--approved',
POSTED: 'ipr-badge--posted',
SUPERSEDED: 'ipr-badge--muted'
}[status] ?? 'ipr-badge--muted'
);
}
get pendingCount(): number {
return this.reports.filter((r) => r.status === 'GENERATED').length;
}
private businessDateIso(): string {
const businessDate = this.settingsService.businessDate as any;
const parsed = businessDate ? new Date(businessDate) : new Date();
if (isNaN(parsed.getTime())) {
return new Date().toISOString().slice(0, 10);
}
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${parsed.getFullYear()}-${month}-${day}`;
}
private messageOf(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? err?.error?.message ?? fallback
);
}
}
@@ -0,0 +1,137 @@
/**
* 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/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export type InterestPostingReportStatus = 'GENERATED' | 'APPROVED' | 'POSTED' | 'SUPERSEDED';
export interface InterestPostingReport {
id: number;
productId: number;
productName: string;
officeId: number;
officeName: string;
postingDate: string;
status: InterestPostingReportStatus;
totalInterest: number;
accountCount: number;
generationMode: 'AUTO' | 'MANUAL';
generatedAt: string;
generatedBy: string | null;
approvedAt: string | null;
approvedBy: string | null;
approvedOnBehalf: boolean;
postedAt: string | null;
failureReason: string | null;
/** Whether the signed-in user may regenerate or approve it: their own branch, or the head office for a branch that did not open. */
canAct: boolean;
}
export interface InterestPostingReportLine {
savingsAccountId: number;
accountNo: string;
clientName: string;
interestAmount: number;
balanceBefore: number;
balanceAfter: number;
savingsTransactionId: number | null;
}
export interface InterestPostingReportDetail extends InterestPostingReport {
payableGlCode: string | null;
payableGlName: string | null;
paidGlCode: string | null;
paidGlName: string | null;
expenseGlCode: string | null;
expenseGlName: string | null;
lines: InterestPostingReportLine[];
}
export interface InterestPostingDue {
productId: number;
productName: string;
officeId: number;
officeName: string;
postingDate: string;
}
export interface InterestPostingSummaryRow {
id: number;
postingDate: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
accountCount: number;
totalInterest: number;
status: InterestPostingReportStatus;
approvedBy: string | null;
approvedOnBehalf: boolean;
approvedAt: string | null;
postedAt: string | null;
}
/**
* The savings interest posting reports, one per branch and product: generated at noon on a posting
* date, approved by the branch, posted when the branch closes the day.
*/
@Injectable({ providedIn: 'root' })
export class InterestPostingReportService {
private http = inject(HttpClient);
private readonly base = '/savingsinterestpostingreports';
list(postingDate?: string, status?: string, officeId?: number): Observable<InterestPostingReport[]> {
let params = new HttpParams();
if (postingDate) params = params.set('postingDate', postingDate);
if (status) params = params.set('status', status);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<InterestPostingReport[]>(this.base, { params });
}
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
dueWithoutReport(postingDate?: string): Observable<InterestPostingDue[]> {
let params = new HttpParams();
if (postingDate) params = params.set('postingDate', postingDate);
return this.http.get<InterestPostingDue[]>(`${this.base}/due-without-report`, { params });
}
detail(reportId: number): Observable<InterestPostingReportDetail> {
return this.http.get<InterestPostingReportDetail>(`${this.base}/${reportId}`);
}
regenerate(
productId: number,
officeId: number,
postingDate: string
): Observable<{ reportId: number; status: string }> {
return this.http.post<{ reportId: number; status: string }>(
this.base,
{ productId, officeId, postingDate },
{ params: new HttpParams().set('command', 'regenerate') }
);
}
approve(reportId: number): Observable<{ reportId: number; status: string }> {
return this.http.post<{ reportId: number; status: string }>(
`${this.base}/${reportId}`,
{},
{ params: new HttpParams().set('command', 'approve') }
);
}
/** The head office's branch-wise view over a date range. */
summary(fromDate?: string, toDate?: string, productId?: number): Observable<InterestPostingSummaryRow[]> {
let params = new HttpParams();
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
if (productId) params = params.set('productId', String(productId));
return this.http.get<InterestPostingSummaryRow[]>(`${this.base}/summary`, { params });
}
}
@@ -0,0 +1,172 @@
<!--
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="ipr">
<div class="ipr-head">
<div>
<h2 class="ipr-head-t"><fa-icon icon="percentage"></fa-icon> Interest Posting Summary</h2>
<div class="ipr-head-s">
Savings interest across every branch: for each posting date, branch and product, how many customers were paid
and how much. Each branch approves and posts its own; this is the head office's view.
</div>
</div>
<div class="ipr-head-a">
<label class="ipr-date">
From
<input type="date" [(ngModel)]="fromDate" (change)="load()" />
</label>
<label class="ipr-date">
To
<input type="date" [(ngModel)]="toDate" (change)="load()" />
</label>
<label class="ipr-date">
Product
<select [(ngModel)]="productId">
<option [ngValue]="null">All products</option>
@for (p of products; track p.id) {
<option [ngValue]="p.id">{{ p.name }}</option>
}
</select>
</label>
<button class="ipr-btn" (click)="load()" [disabled]="loading"><fa-icon icon="undo"></fa-icon> Reload</button>
<button class="ipr-btn ipr-btn--primary" (click)="exportExcel()" [disabled]="loading || shown.length === 0">
<fa-icon icon="file-excel"></fa-icon> Export Excel
</button>
</div>
</div>
@if (errorMessage) {
<div class="ipr-alert ipr-alert--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ errorMessage }}</div>
</div>
}
@if (pendingCount > 0) {
<div class="ipr-alert ipr-alert--warn">
<fa-icon icon="clock"></fa-icon>
<div>{{ pendingCount }} branch report(s) in this period are not posted yet.</div>
</div>
}
<div class="ipr-card">
<div class="ipr-detail-head">
<div>
<h3>Branch totals</h3>
<div class="muted">{{ fromDate }} to {{ toDate }}</div>
</div>
<div class="ipr-total">
<span>Total interest</span>
<strong>₹{{ totalInterest | number: '1.2-2' }}</strong>
<span class="muted">{{ totalCustomers }} customer(s) · posted ₹{{ postedInterest | number: '1.2-2' }}</span>
</div>
</div>
<table class="ipr-table">
<thead>
<tr>
<th>Branch</th>
<th class="num">Reports</th>
<th class="num">Customers</th>
<th class="num">Interest</th>
<th class="num">Posted</th>
<th class="num">Not yet posted</th>
</tr>
</thead>
<tbody>
@for (b of byBranch; track b.officeName) {
<tr>
<td>
<strong>{{ b.officeName }}</strong>
</td>
<td class="num">{{ b.reports }}</td>
<td class="num">{{ b.customers }}</td>
<td class="num">₹{{ b.interest | number: '1.2-2' }}</td>
<td class="num">₹{{ b.posted | number: '1.2-2' }}</td>
<td class="num">₹{{ b.interest - b.posted | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="ipr-empty">
@if (loading) {
Loading…
} @else {
No interest posting report in this period.
}
</td>
</tr>
}
</tbody>
@if (byBranch.length > 0) {
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td class="num">
<strong>{{ shown.length }}</strong>
</td>
<td class="num">
<strong>{{ totalCustomers }}</strong>
</td>
<td class="num">
<strong>₹{{ totalInterest | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>₹{{ postedInterest | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>₹{{ totalInterest - postedInterest | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
}
</table>
</div>
<div class="ipr-card">
<table class="ipr-table">
<thead>
<tr>
<th>Posting date</th>
<th>Branch</th>
<th>Product</th>
<th class="num">Customers</th>
<th class="num">Interest</th>
<th>Status</th>
<th>Approved</th>
<th>Posted</th>
</tr>
</thead>
<tbody>
@for (r of shown; track r.id) {
<tr>
<td>{{ r.postingDate }}</td>
<td>{{ r.officeName }}</td>
<td>
<strong>{{ r.productName }}</strong>
</td>
<td class="num">{{ r.accountCount }}</td>
<td class="num">₹{{ r.totalInterest | number: '1.2-2' }}</td>
<td>
<span class="ipr-badge" [ngClass]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td class="muted">
@if (r.approvedAt) {
{{ r.approvedBy }}
@if (r.approvedOnBehalf) {
<span class="ipr-behalf">(HO)</span>
}
<br />{{ r.approvedAt | date: 'dd MMM, HH:mm' }}
} @else {
—
}
</td>
<td class="muted">{{ r.postedAt ? (r.postedAt | date: 'dd MMM, HH:mm') : '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,244 @@
/**
* 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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SettingsService } from 'app/settings/settings.service';
import { exportWorkbook } from '../cashier/cashier-export';
import {
InterestPostingReportService,
InterestPostingSummaryRow
} from '../interest-posting-report/interest-posting-report.service';
/**
* The head office's view of savings interest across branches: for each posting date, branch and
* product, how many customers were paid and how much, and whether it has been approved and posted.
* Nothing is approved here — each branch approves its own — so it is a report and an Excel export.
*/
@Component({
selector: 'mifosx-interest-posting-summary',
templateUrl: './interest-posting-summary.component.html',
styleUrls: ['../interest-posting-report/interest-posting-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class InterestPostingSummaryComponent implements OnInit {
private reportService = inject(InterestPostingReportService);
private settingsService = inject(SettingsService);
fromDate = '';
toDate = '';
productId: number | null = null;
loading = false;
errorMessage = '';
rows: InterestPostingSummaryRow[] = [];
ngOnInit(): void {
const today = this.businessDateIso();
this.toDate = today;
this.fromDate = `${today.slice(0, 7)}-01`;
this.load();
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.reportService.summary(this.fromDate || undefined, this.toDate || undefined).subscribe({
next: (rows) => {
this.rows = rows ?? [];
this.loading = false;
},
error: (err) => {
this.loading = false;
this.rows = [];
this.errorMessage =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.defaultUserMessage ??
err?.error?.message ??
'Could not load the interest summary.';
}
});
}
/** Products present in the loaded rows, for the filter. */
get products(): { id: number; name: string }[] {
const seen = new Map<number, string>();
this.rows.forEach((r) => seen.set(r.productId, r.productName));
return [...seen.entries()].map(
([
id,
name
]) => ({ id, name })
);
}
get shown(): InterestPostingSummaryRow[] {
return this.productId ? this.rows.filter((r) => r.productId === this.productId) : this.rows;
}
get totalCustomers(): number {
return this.shown.reduce((sum, r) => sum + (r.accountCount || 0), 0);
}
get totalInterest(): number {
return this.shown.reduce((sum, r) => sum + Number(r.totalInterest || 0), 0);
}
get postedInterest(): number {
return this.shown.filter((r) => r.status === 'POSTED').reduce((sum, r) => sum + Number(r.totalInterest || 0), 0);
}
get pendingCount(): number {
return this.shown.filter((r) => r.status !== 'POSTED').length;
}
/** Branch totals, for the second table and the export. */
get byBranch(): { officeName: string; customers: number; interest: number; posted: number; reports: number }[] {
const map = new Map<
string,
{ officeName: string; customers: number; interest: number; posted: number; reports: number }
>();
this.shown.forEach((r) => {
const key = r.officeName || '—';
const row = map.get(key) ?? { officeName: key, customers: 0, interest: 0, posted: 0, reports: 0 };
row.customers += r.accountCount || 0;
row.interest += Number(r.totalInterest || 0);
row.posted += r.status === 'POSTED' ? Number(r.totalInterest || 0) : 0;
row.reports += 1;
map.set(key, row);
});
return [...map.values()].sort((a, b) => a.officeName.localeCompare(b.officeName));
}
statusClass(status: string): string {
return (
{
GENERATED: 'ipr-badge--pending',
APPROVED: 'ipr-badge--approved',
POSTED: 'ipr-badge--posted'
}[status] ?? 'ipr-badge--muted'
);
}
exportExcel(): void {
const period = `Posting dates ${this.fromDate || '…'} to ${this.toDate || '…'}`;
const product = this.productId ? this.products.find((p) => p.id === this.productId)?.name : 'All products';
exportWorkbook(
[
{
sheetName: 'By report',
title: 'Savings Interest — Branch-wise Posting',
subtitle: `${period} · ${product}`,
headers: [
'Posting Date',
'Branch',
'Product',
'Customers',
'Interest',
'Status',
'Approved By',
'Approved At',
'Posted At'
],
widths: [
14,
22,
24,
12,
16,
12,
20,
20,
20
],
rows: [
...this.shown.map((r) => [
r.postingDate,
r.officeName,
r.productName,
r.accountCount,
Number(r.totalInterest || 0),
r.status,
(r.approvedBy || '') + (r.approvedOnBehalf ? ' (HO)' : ''),
r.approvedAt || '',
r.postedAt || ''
]),
[
'TOTAL',
'',
'',
this.totalCustomers,
this.totalInterest,
'',
'',
'',
''
]
]
},
{
sheetName: 'By branch',
title: 'Savings Interest — Branch Totals',
subtitle: `${period} · ${product}`,
headers: [
'Branch',
'Reports',
'Customers',
'Interest',
'Posted',
'Not Yet Posted'
],
widths: [
22,
10,
12,
16,
16,
16
],
rows: [
...this.byBranch.map((b) => [
b.officeName,
b.reports,
b.customers,
b.interest,
b.posted,
b.interest - b.posted
]),
[
'TOTAL',
this.shown.length,
this.totalCustomers,
this.totalInterest,
this.postedInterest,
this.totalInterest - this.postedInterest
]
]
}
],
`savings-interest-branch-summary-${this.fromDate}-to-${this.toDate}`
);
}
private businessDateIso(): string {
const businessDate = this.settingsService.businessDate as any;
const parsed = businessDate ? new Date(businessDate) : new Date();
if (isNaN(parsed.getTime())) {
return new Date().toISOString().slice(0, 10);
}
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${parsed.getFullYear()}-${month}-${day}`;
}
}
@@ -34,6 +34,7 @@ import {
ageInFy, ageInFy,
buildSchedule, buildSchedule,
checkEligibility, checkEligibility,
configuredFys,
declarationFormFor, declarationFormFor,
formKindFor, formKindFor,
isSeniorInFy, isSeniorInFy,
@@ -134,7 +135,10 @@ export class Form15ghComponent implements OnInit {
// Derived once. The template reads these many times per change-detection // Derived once. The template reads these many times per change-detection
// pass, and re-running the schedules on every read is what made the other // pass, and re-running the schedules on every read is what made the other
// register screens stall. // register screens stall.
this.rows = seedForm15gh().map((record) => this.buildRow(record)); // Only years that are configured can be computed; the engine throws on any other.
this.rows = seedForm15gh()
.filter((record) => !!FY_TAX_CONFIG[normaliseFy(record.fy)])
.map((record) => this.buildRow(record));
} }
private buildRow(record: Form15ghRecord): Form15ghRow { private buildRow(record: Form15ghRecord): Form15ghRow {
@@ -219,13 +223,13 @@ export class Form15ghComponent implements OnInit {
* than written into the template. * than written into the template.
*/ */
get statute(): { form: string; section: string; statement: string; frequency: string } { get statute(): { form: string; section: string; statement: string; frequency: string } {
const fy = normaliseFy(this.rows[0]?.record.fy ?? '2026-27'); const fy = normaliseFy(this.rows[0]?.record.fy ?? configuredFys()[0] ?? '');
const cfg = FY_TAX_CONFIG[fy]; const cfg = FY_TAX_CONFIG[fy];
return { return {
form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H', form: cfg?.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
section: cfg.interestTdsSection, section: cfg?.interestTdsSection ?? '—',
statement: cfg.tdsStatementForm, statement: cfg?.tdsStatementForm ?? '—',
frequency: cfg.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly' frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
}; };
} }
@@ -41,6 +41,7 @@ import {
buildSchedule, buildSchedule,
checkEligibility, checkEligibility,
collectTdsCredits, collectTdsCredits,
configuredFys,
declarationFormFor, declarationFormFor,
formKindFor, formKindFor,
isSeniorInFy, isSeniorInFy,
@@ -115,18 +116,15 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy {
/** Which row's quarter-wise breakdown is open. */ /** Which row's quarter-wise breakdown is open. */
expandedId: string | null = null; expandedId: string | null = null;
selectedFy = 'FY 2026-27'; /** Every year configured on Organization › TDS Configuration, loaded by the route resolver. */
readonly availableFys = configuredFys().map((fy) => `FY ${fy}`);
selectedFy = this.availableFys[0] ?? '';
// Per-client caches // Per-client caches
private clientDataCache = new Map<string, Observable<any>>(); private clientDataCache = new Map<string, Observable<any>>();
private clientKycCache = new Map<string, Observable<any>>(); private clientKycCache = new Map<string, Observable<any>>();
private clientAccountsCache = new Map<string, Observable<any>>(); private clientAccountsCache = new Map<string, Observable<any>>();
readonly availableFys = [
'FY 2026-27',
'FY 2025-26'
];
ngOnInit(): void { ngOnInit(): void {
this.loadRealData(); this.loadRealData();
} }
@@ -137,6 +135,12 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy {
} }
loadRealData(): void { loadRealData(): void {
// No configured year means no rates to compute with; report it rather than throw mid-build.
if (!this.selectedFy) {
this.loading = false;
this.loadError = true;
return;
}
this.loading = true; this.loading = true;
this.loadError = false; this.loadError = false;
this.clientDataCache.clear(); this.clientDataCache.clear();
@@ -30,6 +30,9 @@ import {
tdsDeducteeOf, tdsDeducteeOf,
tdsModeFor tdsModeFor
} from './tds-15gh.compute'; } from './tds-15gh.compute';
import { installTdsConfigFixture } from './tds-config.fixture';
installTdsConfigFixture();
const FY = 'FY 2026-27'; const FY = 'FY 2026-27';
@@ -129,73 +129,37 @@ export interface FyTaxConfig {
} }
/** /**
* Statutory figures for the years this book covers, on the new tax regime. * Statutory figures per financial year, keyed '2026-27'.
*
* Nothing is compiled in. The values live in m_tds_fy_config, are edited on
* Organization › TDS Configuration, and are loaded into this registry by
* `TdsConfigService` before any TDS screen opens (see `tdsConfigResolver`). A
* year with no entry makes the computation throw rather than guess.
* *
* For a customer who has opted out onto the old regime, `basicExemption` and * For a customer who has opted out onto the old regime, `basicExemption` and
* `nilTaxCeiling` are lower and vary by age; pass an override through * `nilTaxCeiling` are lower and vary by age; pass an override through
* `TdsContext.config`. * `TdsContext.config`.
*
* Thresholds and rates did not change at the 2026 changeover — ₹50,000,
* ₹1,00,000 for senior citizens, 10%, and 20% without PAN carried straight
* across. What changed is the declaration and the return it is reported in.
*/ */
export const FY_TAX_CONFIG: Record<string, FyTaxConfig> = { export const FY_TAX_CONFIG: Record<string, FyTaxConfig> = {};
'2025-26': {
fy: '2025-26', /** Bumped on every load, so memoised computations can tell one configuration from the next. */
regime: 'ITA_1961', export let fyTaxConfigVersion = 0;
interestTdsSection: 'section 194A',
thresholdSection: 'section 194A(3)(i)', /** Replaces the whole registry with what the platform holds. */
noPanSection: 'section 206AA', export function setFyTaxConfigs(configs: FyTaxConfig[]): void {
defaultSection: 'section 201(1A)', for (const key of Object.keys(FY_TAX_CONFIG)) {
declarationForm: '15G', delete FY_TAX_CONFIG[key];
declarationRule: 'Rule 29C',
declarationSection: 'section 197A',
tdsStatementForm: 'Form 26Q',
tdsStatementRule: 'Rule 31A',
certificateForm: 'Form 16A',
declarationStatementFrequency: 'QUARTERLY',
declarationStatementDueDay: 15,
thresholdGeneral: 50_000,
thresholdSenior: 100_000,
rate: 0.1,
rateNoPan: 0.2,
basicExemption: 400_000,
nilTaxCeiling: 1_200_000,
roundTo: 1,
lateDeductionRate: 0.01,
lateDepositRate: 0.015
},
'2026-27': {
fy: '2026-27',
regime: 'ITA_2025',
// Old section 194A was folded into the omnibus TDS section of the 2025 Act.
interestTdsSection: 'section 393(1) [Table Sl. 5(ii)/(iii)]',
thresholdSection: 'section 393(4) [Table Sl. 7]',
// 206AA and 206CC merged here. The reduced 5% limb applies only to Sl. 8(ii)
// and 8(v), so interest stays at 20% without PAN.
noPanSection: 'section 397(2)',
defaultSection: 'section 398',
declarationForm: '121',
declarationRule: 'Rule 211 of the Income-tax Rules, 2026',
declarationSection: 'section 393(6)',
tdsStatementForm: 'Form 140',
tdsStatementRule: 'Rule 219 of the Income-tax Rules, 2026',
certificateForm: 'Form 131',
// The consolidated statement of declarations received moved from a quarterly
// upload to a monthly one, due on the 7th of the following month.
declarationStatementFrequency: 'MONTHLY',
declarationStatementDueDay: 7,
thresholdGeneral: 50_000,
thresholdSenior: 100_000,
rate: 0.1,
rateNoPan: 0.2,
basicExemption: 400_000,
nilTaxCeiling: 1_200_000,
roundTo: 1,
lateDeductionRate: 0.01,
lateDepositRate: 0.015
} }
}; for (const cfg of configs || []) {
FY_TAX_CONFIG[normaliseFy(cfg.fy)] = cfg;
}
fyTaxConfigVersion++;
}
/** The configured years, latest first. */
export function configuredFys(): string[] {
return Object.keys(FY_TAX_CONFIG).sort().reverse();
}
// ─────────────────────── Unique Identification Number ─────────────────────── // ─────────────────────── Unique Identification Number ───────────────────────
@@ -0,0 +1,71 @@
/**
* 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/.
*/
/**
* Test-only TDS configuration. The application reads these figures from Organization › TDS
* Configuration; the specs install this copy of the seeded values so they run without a backend.
*/
import { FyTaxConfig, setFyTaxConfigs } from './tds-15gh.compute';
export const TDS_CONFIG_FIXTURE: FyTaxConfig[] = [
{
fy: '2025-26',
regime: 'ITA_1961',
interestTdsSection: 'section 194A',
thresholdSection: 'section 194A(3)(i)',
noPanSection: 'section 206AA',
defaultSection: 'section 201(1A)',
declarationForm: '15G',
declarationRule: 'Rule 29C',
declarationSection: 'section 197A',
tdsStatementForm: 'Form 26Q',
tdsStatementRule: 'Rule 31A',
certificateForm: 'Form 16A',
declarationStatementFrequency: 'QUARTERLY',
declarationStatementDueDay: 15,
thresholdGeneral: 50_000,
thresholdSenior: 100_000,
rate: 0.1,
rateNoPan: 0.2,
basicExemption: 400_000,
nilTaxCeiling: 1_200_000,
roundTo: 1,
lateDeductionRate: 0.01,
lateDepositRate: 0.015
},
{
fy: '2026-27',
regime: 'ITA_2025',
interestTdsSection: 'section 393(1) [Table Sl. 5(ii)/(iii)]',
thresholdSection: 'section 393(4) [Table Sl. 7]',
noPanSection: 'section 397(2)',
defaultSection: 'section 398',
declarationForm: '121',
declarationRule: 'Rule 211 of the Income-tax Rules, 2026',
declarationSection: 'section 393(6)',
tdsStatementForm: 'Form 140',
tdsStatementRule: 'Rule 219 of the Income-tax Rules, 2026',
certificateForm: 'Form 131',
declarationStatementFrequency: 'MONTHLY',
declarationStatementDueDay: 7,
thresholdGeneral: 50_000,
thresholdSenior: 100_000,
rate: 0.1,
rateNoPan: 0.2,
basicExemption: 400_000,
nilTaxCeiling: 1_200_000,
roundTo: 1,
lateDeductionRate: 0.01,
lateDepositRate: 0.015
}
];
export function installTdsConfigFixture(): void {
setFyTaxConfigs(TDS_CONFIG_FIXTURE);
}
@@ -0,0 +1,150 @@
/**
* 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/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ResolveFn } from '@angular/router';
import { Observable, catchError, map, of, tap } from 'rxjs';
import { FyTaxConfig, setFyTaxConfigs } from './tds-15gh.compute';
/** One financial year as the platform stores it — percentages as entered, e.g. 10 for 10%. */
export interface TdsFyConfigRecord {
id?: number;
fy: string;
regime: string;
interestTdsSection: string | null;
thresholdSection: string | null;
noPanSection: string | null;
defaultSection: string | null;
declarationForm: string;
declarationRule: string | null;
declarationSection: string | null;
tdsStatementForm: string | null;
tdsStatementRule: string | null;
certificateForm: string | null;
declarationStatementFrequency: string;
declarationStatementDueDay: number;
thresholdGeneral: number;
thresholdSenior: number;
ratePanPercentage: number;
rateNoPanPercentage: number;
basicExemption: number;
nilTaxCeiling: number;
roundTo: number;
lateDeductionPercentage: number;
lateDepositPercentage: number;
seedDefault?: boolean;
updatedOn?: string | null;
}
export interface TdsGlRole {
configKey: string;
label: string;
description: string;
glAccountId: number | null;
glCode: string | null;
glAccountName: string | null;
}
export interface TdsConfigResponse {
years: TdsFyConfigRecord[];
glRoles: TdsGlRole[];
}
/**
* The single source of TDS thresholds, rates and statutory references.
*
* Every TDS screen computes from `FY_TAX_CONFIG`, and this service is the only thing that fills it.
* Saves go through here too, so the registry is refreshed the moment an administrator changes a
* figure and no open screen keeps computing on the old one.
*/
@Injectable({ providedIn: 'root' })
export class TdsConfigService {
private http = inject(HttpClient);
private last: TdsConfigResponse | null = null;
/** The GL roles as last loaded. */
get glRoles(): TdsGlRole[] {
return this.last?.glRoles ?? [];
}
/** Reads the configuration and installs it into the engine. */
load(): Observable<TdsConfigResponse> {
return this.http.get<TdsConfigResponse>('/tdsconfig').pipe(tap((res) => this.install(res)));
}
/** For route resolvers: never blocks navigation, the screen reports a missing year itself. */
ensureLoaded(): Observable<boolean> {
return this.load().pipe(
map(() => true),
catchError(() => {
setFyTaxConfigs([]);
return of(false);
})
);
}
template(): Observable<any> {
return this.http.get('/tdsconfig/template');
}
saveYear(record: TdsFyConfigRecord): Observable<TdsConfigResponse> {
return this.http
.put<TdsConfigResponse>(`/tdsconfig/year/${record.fy}`, record)
.pipe(tap((res) => this.install(res)));
}
deleteYear(fy: string): Observable<TdsConfigResponse> {
return this.http.delete<TdsConfigResponse>(`/tdsconfig/year/${fy}`).pipe(tap((res) => this.install(res)));
}
saveGl(configKey: string, glAccountId: number | null): Observable<TdsConfigResponse> {
return this.http
.put<TdsConfigResponse>(`/tdsconfig/gl/${configKey}`, { glAccountId })
.pipe(tap((res) => this.install(res)));
}
private install(res: TdsConfigResponse): void {
this.last = res;
setFyTaxConfigs((res?.years || []).map(toFyTaxConfig));
}
}
/** Stored percentages become the fractions the engine multiplies by. */
export function toFyTaxConfig(r: TdsFyConfigRecord): FyTaxConfig {
return {
fy: r.fy,
regime: r.regime as FyTaxConfig['regime'],
interestTdsSection: r.interestTdsSection ?? '—',
thresholdSection: r.thresholdSection ?? '—',
noPanSection: r.noPanSection ?? '—',
defaultSection: r.defaultSection ?? '—',
declarationForm: r.declarationForm as FyTaxConfig['declarationForm'],
declarationRule: r.declarationRule ?? '—',
declarationSection: r.declarationSection ?? '—',
tdsStatementForm: r.tdsStatementForm ?? '—',
tdsStatementRule: r.tdsStatementRule ?? '—',
certificateForm: r.certificateForm ?? '—',
declarationStatementFrequency: r.declarationStatementFrequency === 'MONTHLY' ? 'MONTHLY' : 'QUARTERLY',
declarationStatementDueDay: Number(r.declarationStatementDueDay),
thresholdGeneral: Number(r.thresholdGeneral),
thresholdSenior: Number(r.thresholdSenior),
rate: Number(r.ratePanPercentage) / 100,
rateNoPan: Number(r.rateNoPanPercentage) / 100,
basicExemption: Number(r.basicExemption),
nilTaxCeiling: Number(r.nilTaxCeiling),
roundTo: Number(r.roundTo) || 1,
lateDeductionRate: Number(r.lateDeductionPercentage) / 100,
lateDepositRate: Number(r.lateDepositPercentage) / 100
};
}
/** Put on every route whose screen computes TDS, so the figures are in place before it builds. */
export const tdsConfigResolver: ResolveFn<boolean> = () => inject(TdsConfigService).ensureLoaded();
@@ -16,6 +16,7 @@ import { ShareCertificateComponent } from './certificates/share-certificate/shar
import { BalanceCertificateComponent } from './certificates/balance-certificate/balance-certificate.component'; import { BalanceCertificateComponent } from './certificates/balance-certificate/balance-certificate.component';
import { Form60Component } from './certificates/form-60/form-60.component'; import { Form60Component } from './certificates/form-60/form-60.component';
import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component'; import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component';
import { tdsConfigResolver } from './certificates/form-15gh/tds-config.service';
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component'; import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
import { NocCertificateComponent } from './certificates/noc/noc.component'; import { NocCertificateComponent } from './certificates/noc/noc.component';
import { DatabaseExportComponent } from './database-export/database-export.component'; import { DatabaseExportComponent } from './database-export/database-export.component';
@@ -68,6 +69,7 @@ const routes: Routes = [
{ {
path: 'letter-mgmt/form-15gh', path: 'letter-mgmt/form-15gh',
component: Form15ghComponent, component: Form15ghComponent,
resolve: { tdsConfig: tdsConfigResolver },
data: { title: 'Form 121', breadcrumb: 'Form 121' } data: { title: 'Form 121', breadcrumb: 'Form 121' }
}, },
{ {
+1 -1
View File
@@ -438,7 +438,7 @@ export class LoginComponent implements OnInit, OnDestroy {
.split(',') .split(',')
.map((id) => id.trim()) .map((id) => id.trim())
.filter((id) => id.length > 0); .filter((id) => id.length > 0);
if (tenantIds.length === 0 || (tenantIds.length === 1 && tenantIds[0] === 'default')) { if (tenantIds.length === 0) {
return false; return false;
} }
return true; return true;
@@ -143,11 +143,25 @@ export class BranchProfileComponent implements OnInit {
.subscribe((res: any) => { .subscribe((res: any) => {
this.saving = false; this.saving = false;
if (!res) return; if (!res) return;
if (res.rollbackTransaction) {
// Parked for checker approval - the branch profile has not actually changed yet.
this.successMsg = 'Branch profile update has been submitted for approval.';
this.form.markAsPristine();
return;
}
this.successMsg = 'Branch profile saved.'; this.successMsg = 'Branch profile saved.';
this.form.markAsPristine(); this.form.markAsPristine();
this.lastUpdated = res.updatedByUsername ? { by: res.updatedByUsername, at: res.updatedAt } : null; // The response is now a CommandProcessingResult, not the updated row (the write goes through
const idx = this.offices.findIndex((o) => o.officeId === this.selectedOfficeId); // the command pipeline) - reload the saved row from the server instead of patching state from
if (idx >= 0) this.offices[idx] = res; // this response.
this.organizationService.getBranchProfile(this.selectedOfficeId!).subscribe((updated: any) => {
if (!updated) return;
this.lastUpdated = updated.updatedByUsername
? { by: updated.updatedByUsername, at: updated.updatedAt }
: null;
const idx = this.offices.findIndex((o) => o.officeId === this.selectedOfficeId);
if (idx >= 0) this.offices[idx] = updated;
});
}); });
} }
} }
@@ -42,6 +42,7 @@ import { AsyncPipe } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatGridList, MatGridTile } from '@angular/material/grid-list'; import { MatGridList, MatGridTile } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/** /**
* Manage Currencies component. * Manage Currencies component.
@@ -68,6 +69,7 @@ export class ManageCurrenciesComponent implements OnInit, AfterViewInit, OnDestr
private translateService = inject(TranslateService); private translateService = inject(TranslateService);
private configurationWizardService = inject(ConfigurationWizardService); private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService); private popoverService = inject(PopoverService);
private alertService = inject(AlertService);
//** Defining PlaceHolders for the search bar */ //** Defining PlaceHolders for the search bar */
placeHolderLabel = ''; placeHolderLabel = '';
@@ -164,8 +166,17 @@ export class ManageCurrenciesComponent implements OnInit, AfterViewInit, OnDestr
if (!selectedCurrencyCodes.includes(newCurrency.code)) { if (!selectedCurrencyCodes.includes(newCurrency.code)) {
selectedCurrencyCodes.push(newCurrency.code); selectedCurrencyCodes.push(newCurrency.code);
this.organizationservice.updateCurrencies(selectedCurrencyCodes).subscribe((response: any) => { this.organizationservice.updateCurrencies(selectedCurrencyCodes).subscribe((response: any) => {
this.selectedCurrencies.push(newCurrency);
this.formRef.resetForm(); this.formRef.resetForm();
if (response?.rollbackTransaction) {
// Parked for checker approval - the currency has not actually been added yet, so the
// local list must not be mutated as if it had.
this.alertService.alert({
type: 'Success',
message: 'Currency configuration update has been submitted for approval.'
});
return;
}
this.selectedCurrencies.push(newCurrency);
if (this.configurationWizardService.showCurrencyForm) { if (this.configurationWizardService.showCurrencyForm) {
this.configurationWizardService.showCurrencyForm = false; this.configurationWizardService.showCurrencyForm = false;
this.openDialog(); this.openDialog();
@@ -187,9 +198,18 @@ export class ManageCurrenciesComponent implements OnInit, AfterViewInit, OnDestr
}); });
deleteCurrencyDialogRef.afterClosed().subscribe((response: any) => { deleteCurrencyDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) { if (response.delete) {
this.organizationservice.updateCurrencies(selectedCurrencyCodes).subscribe(() => { this.organizationservice.updateCurrencies(selectedCurrencyCodes).subscribe((updateResponse: any) => {
this.selectedCurrencies.splice(index, 1);
this.formRef.resetForm(); this.formRef.resetForm();
if (updateResponse?.rollbackTransaction) {
// Parked for checker approval - the currency has not actually been removed yet, so the
// local list must not be mutated as if it had.
this.alertService.alert({
type: 'Success',
message: 'Currency configuration update has been submitted for approval.'
});
return;
}
this.selectedCurrencies.splice(index, 1);
}); });
} }
}); });
@@ -182,9 +182,18 @@ export class InstitutionProfileComponent implements OnInit {
.subscribe((res: any) => { .subscribe((res: any) => {
this.saving = false; this.saving = false;
if (!res) return; if (!res) return;
if (res.rollbackTransaction) {
// Parked for checker approval - the profile has not actually changed yet, so the form must
// not be patched with the submitted (not-yet-applied) values as if it had.
this.successMsg = 'Bank profile update has been submitted for approval.';
this.form.markAsPristine();
return;
}
this.successMsg = 'Bank profile saved.'; this.successMsg = 'Bank profile saved.';
this.applyResult(res); // The response is now a CommandProcessingResult, not the updated row (the write goes through
this.form.markAsPristine(); // the command pipeline) - reload the saved row from the server instead of patching the form
// from this response.
this.load();
}); });
} }
@@ -11,6 +11,7 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule }
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { OrganizationService } from 'app/organization/organization.service'; import { OrganizationService } from 'app/organization/organization.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
@Component({ @Component({
selector: 'mifosx-create-fund', selector: 'mifosx-create-fund',
@@ -25,6 +26,7 @@ export class CreateFundComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private alertService = inject(AlertService);
/** Charge form. */ /** Charge form. */
fundForm: UntypedFormGroup; fundForm: UntypedFormGroup;
@@ -49,6 +51,12 @@ export class CreateFundComponent implements OnInit {
submit() { submit() {
const payload = this.fundForm.getRawValue(); const payload = this.fundForm.getRawValue();
this.organizationService.createFund(payload).subscribe((response: any) => { this.organizationService.createFund(payload).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Fund creation has been submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route }); this.router.navigate(['../'], { relativeTo: this.route });
}); });
} }
@@ -261,6 +261,32 @@ const routes: Routes = [
import('./clearing-gl-config/clearing-gl-config.component').then((m) => m.ClearingGlConfigComponent), import('./clearing-gl-config/clearing-gl-config.component').then((m) => m.ClearingGlConfigComponent),
data: { title: 'Clearing GL Configuration', breadcrumb: 'Clearing GL Configuration' } data: { title: 'Clearing GL Configuration', breadcrumb: 'Clearing GL Configuration' }
}, },
{
// CRR/SLR percentages and the GL accounts RBI Form 1 and Form 9 read their figures from.
path: 'rbi-reserve-config',
loadComponent: () =>
import('./rbi-reserve-config/rbi-reserve-config.component').then((m) => m.RbiReserveConfigComponent),
data: { title: 'RBI Reserve Configuration', breadcrumb: 'RBI Reserve Configuration' }
},
{
// TDS thresholds, rates and statutory references per FY, and the TDS payable GL.
path: 'tds-config',
loadComponent: () => import('./tds-config/tds-config.component').then((m) => m.TdsConfigComponent),
data: { title: 'TDS Configuration', breadcrumb: 'TDS Configuration' }
},
{
// OSS 1 keeps its own screen: sixteen roles of its own, none shared with CRR or SLR.
path: 'oss1-gl-config',
loadComponent: () =>
import('./rbi-reserve-config/oss1-gl-config.component').then((m) => m.Oss1GlConfigComponent),
data: { title: 'OSS 1 GL Configuration', breadcrumb: 'OSS 1 GL Configuration' }
},
{
path: 'oss2-gl-config',
loadComponent: () =>
import('./rbi-reserve-config/oss2-gl-config.component').then((m) => m.Oss2GlConfigComponent),
data: { title: 'OSS 2 GL Configuration', breadcrumb: 'OSS 2 GL Configuration' }
},
{ {
path: 'employees', path: 'employees',
data: { title: 'Manage Employees', breadcrumb: 'Manage Employees' }, data: { title: 'Manage Employees', breadcrumb: 'Manage Employees' },
@@ -76,6 +76,64 @@
</a> </a>
</div> </div>
<!-- RBI Reserve Configuration -->
<div #rbiReserveConfig>
<a class="org-card" [routerLink]="['rbi-reserve-config']" *mifosxHasPermission="'READ_RBIRESERVECONFIG'">
<div class="org-card__icon org-card__icon--amber">
<i class="fas fa-percent"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">RBI Reserve Configuration</h3>
<p class="org-card__desc">CRR and SLR rates, and the GL accounts behind Form 1 and Form 9</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- TDS Configuration -->
<div>
<a class="org-card" [routerLink]="['tds-config']" *mifosxHasPermission="'READ_TDSCONFIG'">
<div class="org-card__icon org-card__icon--amber">
<i class="fas fa-receipt"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">TDS Configuration</h3>
<p class="org-card__desc">TDS thresholds and rates per financial year, and the TDS payable GL</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- OSS 1 GL Configuration -->
<div #oss1GlConfig>
<a class="org-card" [routerLink]="['oss1-gl-config']" *mifosxHasPermission="'READ_RBIRESERVECONFIG'">
<div class="org-card__icon org-card__icon--amber">
<i class="fas fa-scale-balanced"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">OSS 1 GL Configuration</h3>
<p class="org-card__desc">
GL accounts behind OSS 1 — Return on Assets and Liabilities, read as closing balances
</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- OSS 2 GL Configuration -->
<div #oss2GlConfig>
<a class="org-card" [routerLink]="['oss2-gl-config']" *mifosxHasPermission="'READ_RBIRESERVECONFIG'">
<div class="org-card__icon org-card__icon--amber">
<i class="fas fa-chart-line"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">OSS 2 GL Configuration</h3>
<p class="org-card__desc">GL accounts behind OSS 2 — Return on Earnings, read as quarterly movement</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- Currency Configuration --> <!-- Currency Configuration -->
<div #addEditCurrency> <div #addEditCurrency>
<a class="org-card" [routerLink]="['currencies']" *mifosxHasPermission="'READ_CURRENCY'"> <a class="org-card" [routerLink]="['currencies']" *mifosxHasPermission="'READ_CURRENCY'">
@@ -935,4 +935,71 @@ export class OrganizationService {
updateClearingGlConfig(configKey: string, glAccountId: number | null): Observable<any> { updateClearingGlConfig(configKey: string, glAccountId: number | null): Observable<any> {
return this.http.put(`/clearingglconfig/${configKey}`, { glAccountId }); return this.http.put(`/clearingglconfig/${configKey}`, { glAccountId });
} }
// ── RBI Reserve Configuration (Form 1 CRR / Form 9 SLR) ──
/**
* @returns {Observable<any>} `{ rates[], glRoles[] }` — the effective-dated CRR/SLR percentages
* and the GL accounts mapped to each reserve role. Roles with nothing mapped are included, so
* the screen can show the gaps rather than hide them.
*/
getRbiReserveConfig(): Observable<any> {
return this.http.get('/rbireserveconfig');
}
/** @returns {Observable<any>} Selectable (postable, non-header) GL accounts plus the rate types. */
getRbiReserveConfigTemplate(): Observable<any> {
return this.http.get('/rbireserveconfig/template');
}
/**
* Replaces the entire set of GL accounts mapped to one reserve role.
*
* Each mapping carries the portion of the account's balance the role takes and, on a liability
* head, whether that portion is a demand or a time liability. RBI treats part of a savings balance
* as demand and the rest as time, so the same account is sent twice under one role with different
* classes - which is why this is a list of mappings rather than a list of account ids.
*
* @param {string} configKey A built-in role (CASH_IN_HAND, BALANCE_WITH_OTHER_BANKS,
* DEMAND_TIME_LIABILITIES, SLR_INVESTMENTS) or the key of a head an administrator added.
*/
updateRbiReserveGlRole(
configKey: string,
mappings: { glAccountId: number; weightPercentage: number; liabilityClass: string | null }[]
): Observable<any> {
return this.http.put(`/rbireserveconfig/gl/${configKey}`, { mappings, locale: 'en' });
}
/**
* Adds a head to a return - a reserve, a tax payable, anything the bank's chart carries that the
* built-in roles do not name. `usage` states how the return consumes it: LIABILITY, ASSET_NET,
* MAINTAINED or MEMO.
*/
createRbiReserveRole(role: { label: string; description: string; group: string; usage: string }): Observable<any> {
return this.http.post('/rbireserveconfig/role', { ...role, locale: 'en' });
}
/** Removes an administrator-defined head and the accounts pinned to it. */
deleteRbiReserveRole(roleId: number): Observable<any> {
return this.http.delete(`/rbireserveconfig/role/${roleId}`);
}
/**
* Sets a CRR or SLR rate with effect from a date. Restating an existing effective date edits that
* row; a new date adds one, so past returns keep the rate that was actually in force.
*/
upsertRbiReserveRate(rateType: string, percentage: number, effectiveFrom: string): Observable<any> {
return this.http.post('/rbireserveconfig/rate', {
rateType,
percentage,
effectiveFrom,
locale: 'en',
dateFormat: 'yyyy-MM-dd'
});
}
/** @returns {Observable<any>} */
deleteRbiReserveRate(rateId: number): Observable<any> {
return this.http.delete(`/rbireserveconfig/rate/${rateId}`);
}
} }
@@ -16,6 +16,7 @@ import { OrganizationService } from '../../organization.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/** /**
* Create Payment Type Component. * Create Payment Type Component.
@@ -35,6 +36,7 @@ export class CreatePaymentTypeComponent implements OnInit {
private organizationService = inject(OrganizationService); private organizationService = inject(OrganizationService);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private alertService = inject(AlertService);
/** Payment Type form. */ /** Payment Type form. */
paymentTypeForm: UntypedFormGroup; paymentTypeForm: UntypedFormGroup;
@@ -73,7 +75,13 @@ export class CreatePaymentTypeComponent implements OnInit {
*/ */
submit() { submit() {
const paymentType = this.paymentTypeForm.value; const paymentType = this.paymentTypeForm.value;
this.organizationService.createPaymentType(paymentType).subscribe((response) => { this.organizationService.createPaymentType(paymentType).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Payment Type creation has been submitted for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route }); this.router.navigate(['../'], { relativeTo: this.route });
}); });
} }
@@ -16,6 +16,7 @@ import { OrganizationService } from 'app/organization/organization.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/** /**
* Edit Payment Type component. * Edit Payment Type component.
@@ -35,6 +36,7 @@ export class EditPaymentTypeComponent implements OnInit {
private organizationService = inject(OrganizationService); private organizationService = inject(OrganizationService);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private alertService = inject(AlertService);
/** Payment Type form. */ /** Payment Type form. */
paymentTypeForm: UntypedFormGroup; paymentTypeForm: UntypedFormGroup;
@@ -88,7 +90,13 @@ export class EditPaymentTypeComponent implements OnInit {
*/ */
submit() { submit() {
const paymentType = this.paymentTypeForm.value; const paymentType = this.paymentTypeForm.value;
this.organizationService.updatePaymentType(this.paymentTypeData.id, paymentType).subscribe((response) => { this.organizationService.updatePaymentType(this.paymentTypeData.id, paymentType).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Payment Type update has been submitted for approval.'
});
}
this.router.navigate(['../../'], { relativeTo: this.route }); this.router.navigate(['../../'], { relativeTo: this.route });
}); });
} }
@@ -34,6 +34,7 @@ import { DeleteDialogComponent } from '../../shared/delete-dialog/delete-dialog.
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/** /**
* Payment Types component. * Payment Types component.
@@ -65,6 +66,7 @@ export class PaymentTypesComponent implements OnInit {
private organizationService = inject(OrganizationService); private organizationService = inject(OrganizationService);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
private alertService = inject(AlertService);
/** Payment Types data. */ /** Payment Types data. */
paymentTypesData: any; paymentTypesData: any;
@@ -132,9 +134,20 @@ export class PaymentTypesComponent implements OnInit {
}); });
deletePaymentTypeDialogRef.afterClosed().subscribe((response: any) => { deletePaymentTypeDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) { if (response.delete) {
this.organizationService.deletePaymentType(paymentTypeId).subscribe(() => { this.organizationService.deletePaymentType(paymentTypeId).subscribe((deleteResponse: any) => {
this.paymentTypesData = this.paymentTypesData.filter((paymentType: any) => paymentType.id !== paymentTypeId); if (deleteResponse?.rollbackTransaction) {
this.setPaymentTypes(); // Parked for checker approval - the record has not actually been removed yet, so the
// list must not drop it (that would falsely show the deletion as already applied).
this.alertService.alert({
type: 'Success',
message: 'Payment Type deletion has been submitted for approval.'
});
} else {
this.paymentTypesData = this.paymentTypesData.filter(
(paymentType: any) => paymentType.id !== paymentTypeId
);
this.setPaymentTypes();
}
}); });
} }
}); });
@@ -0,0 +1,47 @@
/**
* 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/.
*/
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RbiReserveConfigComponent } from './rbi-reserve-config.component';
/**
* GL configuration for OSS 1 — Return on Assets and Liabilities.
*
* Its own screen rather than a section of the reserve configuration: OSS 1 has sixteen roles of its
* own and shares none of them with CRR or SLR, so the two are separate decisions made at separate
* times. The mapping mechanics are identical, so only the scope and the wording differ.
*
* There are no rates here. OSS 1 states a position; it is not a percentage charged on a base.
*/
@Component({
selector: 'mifosx-oss1-gl-config',
templateUrl: './rbi-reserve-config.component.html',
styleUrls: ['./rbi-reserve-config.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class Oss1GlConfigComponent extends RbiReserveConfigComponent {
protected override readonly group = 'OSS1';
protected override readonly showRates = false;
override get screenTitle(): string {
return 'OSS 1 GL Configuration';
}
override get screenSubtitle(): string {
return (
'The GL accounts OSS 1 — Return on Assets and Liabilities reads its figures from. Each head takes the closing ' +
'balance of its accounts as at the quarter end. A head left unmapped reports nothing rather than a nil figure ' +
'that would read as a real position of zero.'
);
}
}
@@ -0,0 +1,46 @@
/**
* 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/.
*/
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RbiReserveConfigComponent } from './rbi-reserve-config.component';
/**
* GL configuration for OSS 2 — Return on Earnings.
*
* Separate from OSS 1 because the two read their accounts differently, not only because they are
* different returns: OSS 1 takes closing balances, OSS 2 takes the movement over the quarter. The
* same account mapped to both would be right on one and wrong on the other, so the screens are kept
* apart rather than sharing a single list of accounts.
*/
@Component({
selector: 'mifosx-oss2-gl-config',
templateUrl: './rbi-reserve-config.component.html',
styleUrls: ['./rbi-reserve-config.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class Oss2GlConfigComponent extends RbiReserveConfigComponent {
protected override readonly group = 'OSS2';
protected override readonly showRates = false;
override get screenTitle(): string {
return 'OSS 2 GL Configuration';
}
override get screenSubtitle(): string {
return (
'The GL accounts OSS 2 — Return on Earnings reads its figures from. Each item takes the movement over the ' +
'reporting quarter, not a closing balance: an earnings line reports what was earned or spent in the period, so ' +
'the same accounts read as balances would carry forward every earlier quarter as well.'
);
}
}
@@ -0,0 +1,369 @@
<!--
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="rrc-container">
<div class="rrc-header">
<button mat-stroked-button [routerLink]="['/organization']" class="rrc-back">
<i class="fas fa-arrow-left"></i> Organization
</button>
<h2 class="rrc-header__title">{{ screenTitle }}</h2>
</div>
<p class="rrc-sub">{{ screenSubtitle }}</p>
@if (errorMsg) {
<div class="rrc-alert rrc-alert--error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="rrc-alert rrc-alert--success">{{ successMsg }}</div>
}
@if (loading) {
<div class="rrc-loading">Loading…</div>
} @else {
<!-- ═══ Rates — only the reserve returns are charged at a percentage ═══ -->
@if (showRates) {
<div class="rrc-card">
<h3 class="rrc-card__title">Reserve rates</h3>
<p class="rrc-card__hint">
Rates are effective-dated rather than overwritten: a return filed for an earlier period keeps reporting the
rate that was actually in force then. Saving against a date that already has a rate edits it; a new date adds
one.
</p>
<div class="rrc-rate-now">
@for (type of ['CRR', 'SLR', 'SAVINGS_DEMAND']; track type) {
<div class="rrc-rate-now__item">
<span class="rrc-rate-now__label">
{{ type === 'SAVINGS_DEMAND' ? 'Savings demand share' : type }} in force today
</span>
@if (currentRate(type); as rate) {
<span class="rrc-rate-now__value">{{ rate.percentage }}%</span>
@if (rate.seedDefault) {
<span class="rrc-rate-now__since rrc-rate-now__since--warn">
Placeholder — confirm the rate actually in force
</span>
} @else {
<span class="rrc-rate-now__since">since {{ rate.effectiveFrom }}</span>
}
} @else {
<span class="rrc-rate-now__value rrc-rate-now__value--none">Not set</span>
<span class="rrc-rate-now__since">{{ type === 'CRR' ? 'Form 1' : 'Form 9' }} cannot compute</span>
}
</div>
}
</div>
<div class="rrc-rate-form">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__type">
<mat-label>Rate</mat-label>
<mat-select [(ngModel)]="newRate.rateType">
<mat-option value="CRR">CRR — Form 1</mat-option>
<mat-option value="SLR">SLR — Form 9</mat-option>
<!-- Not a reserve ratio, but set by RBI and effective-dated the same way: the share of
savings balances that counts as a demand liability, the rest being time. -->
<mat-option value="SAVINGS_DEMAND">Savings — demand share</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__pct">
<mat-label>Percentage (%)</mat-label>
<input matInput type="number" step="0.0001" min="0" max="100" [(ngModel)]="newRate.percentage" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__date">
<mat-label>Effective from</mat-label>
<input matInput type="date" [(ngModel)]="newRate.effectiveFrom" />
</mat-form-field>
<button mat-raised-button color="primary" [disabled]="savingRate" (click)="saveRate()">
{{ savingRate ? 'Saving…' : 'Save rate' }}
</button>
</div>
<table class="rrc-table">
<thead>
<tr>
<th>Rate</th>
<th>Percentage</th>
<th>Effective from</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@for (rate of rates; track rate.id) {
<tr>
<td>
<span class="rrc-key">{{ rate.rateType }}</span>
</td>
<td class="rrc-num">{{ rate.percentage }}%</td>
<td>{{ rate.effectiveFrom }}</td>
<td>
@if (isFuture(rate)) {
<span class="rrc-badge rrc-badge--future">Future-dated</span>
} @else if (currentRate(rate.rateType)?.id === rate.id) {
<span class="rrc-badge rrc-badge--active">In force</span>
} @else {
<span class="rrc-badge rrc-badge--past">Superseded</span>
}
@if (rate.seedDefault) {
<span class="rrc-badge rrc-badge--seed">Placeholder</span>
}
</td>
<td>
<button mat-button class="rrc-remove" (click)="deleteRate(rate)" title="Remove this rate">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ═══ GL accounts ═══ -->
<div class="rrc-card">
<div class="rrc-gl-head">
<h3 class="rrc-card__title">GL accounts behind each figure</h3>
<!--
The two returns share a base but maintain their requirement in different assets, so an
administrator setting one up is looking at a different set of heads than one setting up
the other. Showing all of them at once buries the few that matter for the task in hand.
-->
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-gl-head__filter">
<mat-label>Configuring</mat-label>
<mat-select [(ngModel)]="formFilter" (selectionChange)="onFormFilterChange()">
@for (f of formFilters; track f.value) {
<mat-option [value]="f.value">{{ f.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<p class="rrc-card__hint">
Every role takes as many accounts as it needs — a bank holds current accounts at several other banks, and each
one counts toward what it maintains. Only postable accounts are listed: a header account's balance is the sum of
its children, so mapping both would count those children twice.
@if (formFilter !== 'ALL') {
Heads that build or net the DTL base are shown for either return, since both charge their rate on that same
base.
}
</p>
@if (unmappedCount) {
<div class="rrc-alert rrc-alert--warn">
{{ unmappedCount }} of {{ glRoles.length }} roles have no accounts mapped. Returns that depend on them will
report why they cannot compute instead of showing a figure.
</div>
}
<!--
One tab per return. The roles of four returns on a single list would bury the four that
drive CRR among the thirty that do not, so each return's roles are configured on their own.
Each tab carries its own unmapped count, so a return that cannot compute is visible without
opening its tab.
-->
@if (showTabs) {
<div class="rrc-tabs" role="tablist">
@for (group of roleGroups; track group.name) {
<button
type="button"
role="tab"
class="rrc-tab"
[class.rrc-tab--active]="group.name === activeGroup"
[attr.aria-selected]="group.name === activeGroup"
(click)="showGroup(group.name)"
>
{{ group.label }}
@if (unmappedIn(group)) {
<span class="rrc-tab__badge">{{ unmappedIn(group) }} unmapped</span>
} @else {
<span class="rrc-tab__badge rrc-tab__badge--done">all mapped</span>
}
</button>
}
</div>
}
@for (role of visibleRoles; track role.configKey) {
<div class="rrc-role" [class.rrc-role--unmapped]="isUnmapped(role)">
<div class="rrc-role__head">
<div>
<span class="rrc-role__name">{{ roleLabel(role) }}</span>
<span class="rrc-key">{{ role.configKey }}</span>
<span class="rrc-role__usedby">{{ roleUsedBy(role) }}</span>
</div>
@if (isUnmapped(role)) {
<span class="rrc-badge rrc-badge--unmapped">Not mapped</span>
} @else {
<span class="rrc-badge rrc-badge--mapped">
{{ selection[role.configKey].length }} account{{ selection[role.configKey].length === 1 ? '' : 's' }}
</span>
}
</div>
<p class="rrc-role__desc">{{ role.description }}</p>
<!--
One row per mapping rather than a multi-select, because a mapping is no longer just an
account: it carries the portion of the balance this head takes, and which head that
portion falls under. A savings account appears twice here - a demand slice and a time
slice - which a multi-select cannot express.
-->
@if (selection[role.configKey]?.length) {
<table class="rrc-map">
<thead>
<tr>
<th>GL account</th>
<th class="rrc-map__narrow">Portion (%)</th>
@if (role.classified) {
<th class="rrc-map__narrow">Head</th>
}
<th class="rrc-map__actions"></th>
</tr>
</thead>
<tbody>
@for (mapping of selection[role.configKey]; track $index; let i = $index) {
<tr [class.rrc-map__row--invalid]="isRowInvalid(mapping)">
<td>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
<mat-select [(ngModel)]="mapping.glAccountId" [disabled]="savingKey === role.configKey">
@for (o of availableAccounts(role, i); track o.id) {
<mat-option [value]="o.id">{{ o.glCode }} · {{ o.name }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td>
<!-- The share of this account's balance the head takes. 100 is the whole of it,
which is what every mapping meant before splits existed. -->
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
<input
matInput
type="number"
min="0"
max="100"
step="0.0001"
[(ngModel)]="mapping.weightPercentage"
[disabled]="savingKey === role.configKey"
/>
</mat-form-field>
</td>
@if (role.classified) {
<td>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
<mat-select [(ngModel)]="mapping.liabilityClass" [disabled]="savingKey === role.configKey">
@for (c of liabilityClasses; track c.value) {
<mat-option [value]="c.value">{{ c.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
}
<td class="rrc-map__actions">
<button mat-button class="rrc-remove" (click)="removeMapping(role, i)" title="Remove this row">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
}
<!--
An account split across rows whose portions do not add to the whole balance is the one
error a split invites and no single row shows: each row looks reasonable while a share of
the balance silently leaves the base.
-->
@for (entry of unbalancedAccounts(role); track entry.glAccountId) {
<div class="rrc-alert rrc-alert--warn rrc-alert--inline">
{{ glLabel(entry.glAccountId) }} is mapped at {{ entry.total }}% in total.
@if (entry.total < 100) {
{{ 100 - entry.total }}% of its balance will not appear on the return.
} @else {
It would be counted {{ entry.total }}% — more than the account holds.
}
</div>
}
<div class="rrc-role__controls">
<button mat-stroked-button (click)="addMapping(role)" [disabled]="savingKey === role.configKey">
<i class="fas fa-plus"></i> Add account
</button>
<button
mat-raised-button
color="primary"
[disabled]="savingKey === role.configKey || !isDirty(role) || hasInvalidRows(role)"
(click)="saveRole(role)"
>
{{ savingKey === role.configKey ? 'Saving…' : 'Save' }}
</button>
@if (!role.builtIn) {
<button mat-button class="rrc-remove" (click)="deleteRole(role)" title="Remove this head">
<i class="fas fa-trash"></i> Remove head
</button>
}
</div>
</div>
}
<!-- ═══ add a head ═══ -->
<div class="rrc-add">
@if (!showAddRole) {
<button mat-stroked-button (click)="showAddRole = true">
<i class="fas fa-plus"></i> Add a head to this return
</button>
<p class="rrc-card__hint">
For anything this bank carries that the heads above do not name — a reserve, a tax payable, bills payable.
The return reads it from the same chart of accounts the balance sheet is built from.
</p>
} @else {
<div class="rrc-add__form">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Name</mat-label>
<input matInput [(ngModel)]="newRole.label" placeholder="e.g. Reserve Fund" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>How the return uses it</mat-label>
<mat-select [(ngModel)]="newRole.usage">
@for (u of roleUsages; track u.value) {
<mat-option [value]="u.value">{{ u.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-add__desc">
<mat-label>Note (optional)</mat-label>
<input matInput [(ngModel)]="newRole.description" placeholder="What this head covers" />
</mat-form-field>
<!--
The section prefixes are a naming convention rather than a field, so they have to be
stated somewhere: an administrator who wants a head on the Memorandum has no way to
discover that naming it so is what puts it there.
-->
<p class="rrc-card__hint rrc-add-role__note">
Naming a head <strong>Memorandum - …</strong>, <strong>Annexure A - …</strong> or
<strong>Annexure B - …</strong> prints it under that section of the return instead of in the main
statement.
</p>
<button mat-raised-button color="primary" [disabled]="addingRole" (click)="addRole()">
{{ addingRole ? 'Adding…' : 'Add head' }}
</button>
<button mat-button (click)="showAddRole = false">Cancel</button>
</div>
}
</div>
</div>
}
</div>
@@ -0,0 +1,495 @@
/**
* 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/.
*/
$primary: #1353a0;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-500: #9aa0a6;
$gray-900: #202124;
$green: #137333;
$amber: #b06000;
$red: #c5221f;
.rrc-container {
max-width: 1040px;
margin: 0 auto;
padding: 20px 16px;
}
.rrc-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 6px;
&__title {
font-size: 18px;
font-weight: 600;
color: $gray-900;
margin: 0;
}
}
.rrc-sub {
margin: 0 0 16px;
font-size: 12px;
color: $gray-500;
max-width: 820px;
line-height: 1.6;
}
.rrc-back {
border-radius: 8px !important;
font-size: 12px !important;
font-weight: 600 !important;
height: 32px !important;
border-color: $border !important;
color: $gray-500 !important;
i {
margin-right: 6px;
}
&:hover {
color: $primary !important;
border-color: $primary !important;
}
}
.rrc-alert {
padding: 10px 14px;
border-radius: 8px;
font-size: 12px;
margin-bottom: 14px;
&--error {
background: #fce8e6;
color: $red;
}
&--success {
background: #e6f4ea;
color: $green;
}
&--warn {
background: #fef7e0;
color: $amber;
}
}
.rrc-loading {
padding: 28px;
text-align: center;
color: $gray-500;
font-size: 13px;
}
.rrc-card {
background: #fff;
border: 1px solid $border;
border-radius: 12px;
padding: 18px;
margin-bottom: 18px;
&__title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
color: $gray-900;
}
&__hint {
margin: 0 0 16px;
font-size: 12px;
color: $gray-500;
line-height: 1.6;
}
}
// ── Rates ──
.rrc-rate-now {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 18px;
&__item {
flex: 1 1 220px;
background: $gray-50;
border: 1px solid $border;
border-radius: 10px;
padding: 12px 14px;
}
&__label {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $gray-500;
margin-bottom: 4px;
}
&__value {
font-size: 22px;
font-weight: 700;
color: $primary;
&--none {
font-size: 15px;
color: $red;
}
}
&__since {
display: block;
margin-top: 2px;
font-size: 11px;
color: $gray-500;
&--warn {
color: $amber;
font-weight: 600;
}
}
}
.rrc-rate-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 18px;
&__type {
flex: 0 1 190px;
}
&__pct {
flex: 0 1 150px;
}
&__date {
flex: 0 1 190px;
}
}
// ── Tables ──
.rrc-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
font-weight: 600;
color: $gray-500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 8px 10px;
border-bottom: 1px solid $border;
}
td {
padding: 9px 10px;
border-bottom: 1px solid $gray-200;
color: $gray-900;
vertical-align: middle;
}
tr:last-child td {
border-bottom: none;
}
}
.rrc-num {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.rrc-key {
display: inline-block;
font-family: 'Roboto Mono', monospace;
font-size: 10.5px;
background: $gray-50;
border: 1px solid $border;
border-radius: 5px;
padding: 2px 6px;
color: $gray-500;
}
.rrc-badge {
display: inline-block;
font-size: 10.5px;
font-weight: 600;
border-radius: 20px;
padding: 3px 10px;
white-space: nowrap;
&--active,
&--mapped {
background: #e6f4ea;
color: $green;
}
&--future {
background: #e8f0fe;
color: $primary;
}
&--past {
background: $gray-50;
color: $gray-500;
}
&--unmapped {
background: #fce8e6;
color: $red;
}
// Shipped with the installation, never confirmed by this bank.
&--seed {
background: #fef7e0;
color: $amber;
margin-left: 6px;
}
}
// ── GL roles ──
// One tab per return, so a long role list is configured a return at a time. The tabs wrap rather
// than scroll: a return whose tab had scrolled out of sight would look like one that does not exist.
.rrc-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0 18px;
border-bottom: 1px solid $border;
padding-bottom: 12px;
}
.rrc-tab {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border: 1px solid $border;
border-radius: 8px;
background: #fff;
font: inherit;
font-size: 13px;
font-weight: 600;
color: $gray-900;
cursor: pointer;
&:hover {
background: $gray-50;
}
&--active {
border-color: $primary;
background: rgb(19 83 160 / 6%);
color: $primary;
}
&__badge {
font-size: 11px;
font-weight: 500;
padding: 1px 7px;
border-radius: 10px;
background: rgb(176 96 0 / 12%);
color: $amber;
// A fully mapped return is worth stating as plainly as an incomplete one, so the tab row can
// be read at a glance instead of by elimination.
&--done {
background: rgb(19 115 51 / 12%);
color: $green;
}
}
}
.rrc-role {
border: 1px solid $border;
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 12px;
// An unmapped role stops a return computing, so it reads as a problem, not a blank.
&--unmapped {
border-color: #f5c6c4;
background: #fffafa;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 4px;
}
&__name {
font-size: 13.5px;
font-weight: 600;
color: $gray-900;
margin-right: 8px;
}
&__usedby {
margin-left: 8px;
font-size: 11px;
color: $primary;
font-weight: 600;
}
&__desc {
margin: 0 0 12px;
font-size: 12px;
color: $gray-500;
line-height: 1.6;
}
&__controls {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
&__select {
flex: 1 1 420px;
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
}
// Spans the add-head form's columns rather than sitting in one of them.
.rrc-add-role__note {
flex-basis: 100%;
margin: 0 !important;
}
// Title and the return filter on one line: the filter says what the list below is showing, so it
// belongs beside the heading rather than buried among the roles.
.rrc-gl-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
&__filter {
width: 260px;
}
}
.rrc-remove {
min-width: 0 !important;
padding: 0 8px !important;
color: $gray-500 !important;
&:hover {
color: $red !important;
}
}
.rrc-chip {
font-size: 11px;
background: #e8f0fe;
color: $primary;
border-radius: 20px;
padding: 3px 10px;
}
@media (width <= 720px) {
.rrc-rate-form__type,
.rrc-rate-form__pct,
.rrc-rate-form__date,
.rrc-role__select {
flex: 1 1 100%;
}
}
/* ── mapping rows ──
A mapping is an account, a portion of its balance, and the head that portion falls under, so it
is laid out as a table rather than as chips: the three parts of one row have to read together. */
.rrc-map {
width: 100%;
border-collapse: collapse;
margin-top: 0.75rem;
th {
text-align: left;
font-size: 0.72rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgb(0 0 0 / 55%);
font-weight: 600;
padding: 0 0.5rem 0.25rem 0;
}
td {
padding: 0.15rem 0.5rem 0.15rem 0;
vertical-align: middle;
}
/* The portion and head columns hold short values; letting them take an equal share of the row
would squeeze the account name, which is the part that identifies the row. */
&__narrow {
width: 8.5rem;
}
&__actions {
width: 3rem;
text-align: right;
}
&__field {
width: 100%;
}
/* An incomplete row is marked where it is, rather than only by the disabled Save button, so it is
clear which row is holding the save back. */
&__row--invalid td {
background: rgb(214 106 0 / 6%);
}
}
.rrc-alert--inline {
margin: 0.5rem 0 0;
font-size: 0.82rem;
}
/* ── adding a head ── */
.rrc-add {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px dashed rgb(0 0 0 / 12%);
&__form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
&__desc {
flex: 1 1 16rem;
}
}
@@ -0,0 +1,572 @@
/**
* 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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { catchError, forkJoin, of } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from '../organization.service';
/**
* One GL account pinned to a role: which account, how much of its balance the role takes, and - on a
* liability head - whether that portion is a demand or a time liability.
*/
export interface ReserveMapping {
glAccountId: number;
weightPercentage: number;
liabilityClass: string | null;
}
/**
* Configuration behind RBI Form 1 (CRR) and Form 9 (SLR): the percentages RBI sets, and which GL
* accounts the returns read their figures from.
*
* Both halves sit on one screen because they are one decision. A percentage applies to a base, and
* an administrator who sets the rate but never maps the accounts produces a return that looks
* configured and reports nothing — so the screen states, per role, whether it is mapped at all.
*
* The accounts cannot be shipped as defaults: this platform seeds two mutually exclusive charts of
* accounts and real tenants match neither exactly, so cash on hand is 100101 on one chart and
* 101001 on another. Picking one here would read as zero on the charts it does not match.
*/
@Component({
selector: 'mifosx-rbi-reserve-config',
templateUrl: './rbi-reserve-config.component.html',
styleUrls: ['./rbi-reserve-config.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class RbiReserveConfigComponent implements OnInit {
private organizationService = inject(OrganizationService);
/**
* Which return's roles this screen configures, as the backend groups them: 'RESERVE' for Form 1
* and Form 9, 'OSS1', 'OSS2'. Null shows every role.
*
* A screen per return rather than one screen for all of them: the four roles that drive CRR and
* the thirty that do not are separate decisions, made by different people at different times,
* and a single list makes each one harder to find than it is to make.
*/
protected readonly group: string | null = 'RESERVE';
/** Whether the CRR/SLR percentages belong on this screen. Only the reserve returns have rates. */
protected readonly showRates: boolean = true;
glRoles: any[] = [];
/** The roles grouped by the return they feed, in backend order. */
roleGroups: { name: string; label: string; roles: any[] }[] = [];
/**
* Which return's configuration is on screen: Form 1, Form 9, or both together.
*
* The two returns share a base but maintain their requirement in different assets, so an
* administrator setting one up is looking at a different set of heads than one setting up the
* other. Showing all twenty-six at once buries the four that matter for the task in hand.
*/
formFilter: 'ALL' | 'FORM_1' | 'FORM_9' | 'FORM_IX' = 'ALL';
/** Every reserve role the backend returned, before the form filter narrows it. */
private allRoles: any[] = [];
/** Re-slices what is on screen. No round trip - the full set is already held. */
onFormFilterChange(): void {
this.glRoles = this.rolesForScreen();
this.rebuildGroups();
}
/**
* The roles this screen shows: those in its own group, narrowed to the return selected above.
*
* The group scope is widened for Form IX. Its heads were seeded into their own group so they sit
* apart on the backend, and a screen fixed to 'RESERVE' would filter every one of them out before
* the return filter ever ran - leaving the Form IX option selectable and its list empty.
*/
private rolesForScreen(): any[] {
const wanted = this.formFilter === 'FORM_IX' ? 'FORM9' : this.group;
const scoped = wanted ? this.allRoles.filter((r: any) => r.group === wanted) : this.allRoles;
return scoped.filter((r: any) => this.belongsToSelectedForm(r));
}
readonly formFilters = [
{ value: 'ALL', label: 'All returns' },
{ value: 'FORM_1', label: 'Form 1 — CRR (fortnightly)' },
{ value: 'FORM_9', label: 'Form 9 — SLR (monthly)' },
{ value: 'FORM_IX', label: 'Form IX — Assets & Liabilities (R408)' }
];
/**
* Whether a head belongs to the return currently selected.
*
* Decided from how the return consumes the head rather than from its name: MAINTAINED is met in
* cash and balances and so is Form 1's, SLR_ASSET is met in securities and gold and so is Form
* 9's, and a head that builds or nets the DTL base belongs to both because both charge their rate
* on that same base. A memo head is stated on either return and so shows on both.
*/
private belongsToSelectedForm(role: any): boolean {
if (this.formFilter === 'ALL') {
return true;
}
// Form IX's heads are placed by the group they were seeded into, not by usage. Its usages -
// LIABILITY and ASSET - read the same as Form 1's but mean something different: on Form IX they
// say which SIDE OF THE BALANCE SHEET a head sits on, not how it enters a ratio. Sixty heads
// showing up under Form 1's filter would bury the eight that belong there.
const group = role.group || role.roleGroup;
if (group === 'FORM9') {
return this.formFilter === 'FORM_IX';
}
if (this.formFilter === 'FORM_IX') {
return false;
}
// The built-in SLR investments role predates the SLR_ASSET usage and still carries MAINTAINED,
// so it is placed by name; every other head is placed by how the return consumes it.
if (role.configKey === 'SLR_INVESTMENTS') {
return this.formFilter === 'FORM_9';
}
const usage = role.usage || role.roleUsage;
if (usage === 'MAINTAINED') {
return this.formFilter === 'FORM_1';
}
if (usage === 'SLR_ASSET') {
return this.formFilter === 'FORM_9';
}
// LIABILITY, ASSET_NET and MEMO all appear on both returns.
return true;
}
/** Which group's roles are on screen. Unused when the screen is scoped to one group. */
activeGroup = '';
rates: any[] = [];
glAccountOptions: any[] = [];
loading = true;
savingKey: string | null = null;
savingRate = false;
errorMsg = '';
successMsg = '';
/**
* Draft of the per-role mapping, keyed by role. Committed on Save.
*
* A mapping is an account plus the portion of it the role takes and, on a liability head, which
* head that portion falls under. It is not a plain list of account ids because one account can
* appear twice under one role: RBI treats part of a savings balance as a demand liability and the
* rest as time, which is two rows against the same account.
*/
selection: Record<string, ReserveMapping[]> = {};
/** The heads a liability can fall under, and the label each carries on screen. */
readonly liabilityClasses = [
{ value: 'DEMAND', label: 'Demand' },
{ value: 'TIME', label: 'Time' }
];
/**
* How a head an administrator adds is consumed by the return.
*
* MAINTAINED and SLR_ASSET are separate because the two returns admit different assets: CRR is
* met in cash and balances with banks, SLR in cash, gold and approved securities. One option for
* both would let a head meant for Form 1 quietly satisfy Form 9.
*/
readonly roleUsages = [
{ value: 'LIABILITY', label: 'Liability — adds to the DTL base (Form 1 & 9)' },
{ value: 'ASSET_NET', label: 'Asset — netted off the base to give NDTL (Form 1 & 9)' },
{ value: 'MAINTAINED', label: 'Counts toward CRR maintained (Form 1)' },
{ value: 'SLR_ASSET', label: 'Counts toward SLR maintained (Form 9)' },
// Form IX has no ratio and no base: its usages say which side of the balance sheet a head sits
// on. LIABILITY above serves its liability side, and this its asset side.
{ value: 'ASSET', label: 'Asset — enters Total Assets (Form IX)' },
{ value: 'MEMO', label: 'Memo — shown on the return but totals nothing' }
];
/** New-head form. */
newRole: { label: string; description: string; usage: string } = { label: '', description: '', usage: 'LIABILITY' };
addingRole = false;
showAddRole = false;
/** New-rate form. Defaults to today so the common case is one edit and Save. */
newRate: { rateType: string; percentage: number | null; effectiveFrom: string } = {
rateType: 'CRR',
percentage: null,
effectiveFrom: new Date().toISOString().slice(0, 10)
};
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
forkJoin({
config: this.organizationService.getRbiReserveConfig().pipe(catchError(() => of(null))),
template: this.organizationService.getRbiReserveConfigTemplate().pipe(catchError(() => of(null)))
}).subscribe(({ config, template }: any) => {
this.loading = false;
if (!config) {
this.errorMsg = 'Could not load RBI reserve configuration.';
return;
}
this.applyConfig(config);
this.glAccountOptions = template?.glAccountOptions || [];
});
}
private applyConfig(config: any): void {
// Kept so the form filter can re-slice without a round trip.
this.allRoles = config.glRoles || [];
// Scoped to this screen's return before anything else, so every count, warning and Save on the
// page speaks about the roles the user can actually see.
this.glRoles = this.rolesForScreen();
this.rates = config.rates || [];
this.selection = {};
for (const role of this.glRoles) {
// Read back what is stored rather than re-derived: a weight or a class the administrator set
// deliberately would otherwise be replaced by a default on the next save.
this.selection[role.configKey] = (role.accounts || []).map((a: any) => ({
glAccountId: a.glAccountId,
weightPercentage: a.weightPercentage === null || a.weightPercentage === undefined ? 100 : +a.weightPercentage,
liabilityClass: a.liabilityClass ?? (role.classified ? 'TIME' : null)
}));
}
this.rebuildGroups();
}
/**
* The roles split by the return they feed, so one screen can configure four returns without
* presenting thirty-four roles as a single list where the four that drive CRR are lost among
* the thirty that do not.
*
* Built from the group the backend states rather than from a key prefix: a role's grouping is
* part of what the return means, and duplicating that rule here would let the two drift.
*/
private rebuildGroups(): void {
const byGroup = new Map<string, { label: string; roles: any[] }>();
for (const role of this.glRoles) {
const group = role.group || 'OTHER';
if (!byGroup.has(group)) {
byGroup.set(group, { label: role.groupLabel || group, roles: [] });
}
byGroup.get(group)!.roles.push(role);
}
this.roleGroups = [...byGroup.entries()].map(
([
name,
entry
]) => ({ name, label: entry.label, roles: entry.roles })
);
// Keep the open tab if it still exists, so saving a role does not throw the user back to the
// first tab with their place lost.
if (!this.roleGroups.some((g) => g.name === this.activeGroup)) {
this.activeGroup = this.roleGroups[0]?.name ?? '';
}
}
/** Tabs only earn their place when the screen shows more than one return's roles. */
get showTabs(): boolean {
return this.roleGroups.length > 1;
}
/** Heading and blurb, overridden per return by the screens scoped to one. */
get screenTitle(): string {
return 'RBI Reserve Configuration';
}
get screenSubtitle(): string {
return (
'The CRR and SLR percentages, and the GL accounts that RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly) ' +
'read their figures from. A rate applies to a base — so a role left unmapped stops its return computing rather ' +
'than reporting a nil figure that looks real.'
);
}
/** A role with nothing mapped makes its return refuse to compute — surfaced, not hidden. */
isUnmapped(role: any): boolean {
return !(this.selection[role.configKey] || []).length;
}
get unmappedCount(): number {
return this.glRoles.filter((r) => this.isUnmapped(r)).length;
}
/** Unmapped roles within one group, so each tab carries its own count. */
unmappedIn(group: { roles: any[] }): number {
return group.roles.filter((r) => this.isUnmapped(r)).length;
}
showGroup(name: string): void {
this.activeGroup = name;
}
get visibleRoles(): any[] {
return this.roleGroups.find((g) => g.name === this.activeGroup)?.roles ?? [];
}
/** True once the draft differs from what is stored, so Save is only offered when useful. */
isDirty(role: any): boolean {
const key = (m: any) => `${m.glAccountId}/${m.liabilityClass ?? ''}/${+m.weightPercentage}`;
const stored = (role.accounts || [])
.map((a: any) => key({ ...a, weightPercentage: a.weightPercentage ?? 100 }))
.sort();
const draft = (this.selection[role.configKey] || []).map(key).sort();
return stored.length !== draft.length || stored.some((k: string, i: number) => k !== draft[i]);
}
/** Adds an empty row for this role, for the administrator to pick an account into. */
addMapping(role: any): void {
// Defaults to the whole balance, and to Time on a liability head. Understating demand is the
// conservative default: demand attracts the heavier treatment, so a forgotten class cannot make
// a bank look compliant when it is not.
this.selection[role.configKey] = [
...(this.selection[role.configKey] || []),
{ glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null }];
}
removeMapping(role: any, index: number): void {
this.selection[role.configKey] = (this.selection[role.configKey] || []).filter((_, i) => i !== index);
}
/**
* Accounts still selectable in a given row: everything not already used by ANOTHER row under the
* same head. The same account under a different head is left available, because a demand slice and
* a time slice of one savings account are two different figures, not a duplicate.
*/
availableAccounts(role: any, index: number): any[] {
const rows = this.selection[role.configKey] || [];
const taken = new Set(rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`));
const current = rows[index];
return this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`));
}
/** A row that cannot be saved - no account chosen, or a portion outside 0-100. */
isRowInvalid(mapping: ReserveMapping): boolean {
const weight = +mapping.weightPercentage;
return !mapping.glAccountId || isNaN(weight) || weight < 0 || weight > 100;
}
/** Whether any row of this role is incomplete, so Save is withheld rather than failing server-side. */
hasInvalidRows(role: any): boolean {
return (this.selection[role.configKey] || []).some((m) => this.isRowInvalid(m));
}
/**
* The demand and time subtotals this role's rows declare, as a share of the balances behind them.
*
* Shown because a split is easy to get wrong in a way no single row looks wrong in: two rows
* against one account that together take 90% of it silently drop a tenth of the balance out of the
* base, and only the total reveals it.
*/
weightTotalFor(role: any, glAccountId: number): number {
return (this.selection[role.configKey] || [])
.filter((m) => m.glAccountId === glAccountId)
.reduce((sum, m) => sum + (+m.weightPercentage || 0), 0);
}
/** Accounts split across more than one row whose portions do not add up to the whole balance. */
unbalancedAccounts(role: any): { glAccountId: number; total: number }[] {
const rows = this.selection[role.configKey] || [];
const ids = [...new Set(rows.map((m) => m.glAccountId).filter((id) => !!id))];
return ids
.map((id) => ({ glAccountId: id, total: this.weightTotalFor(role, id) }))
.filter((e) => Math.abs(e.total - 100) > 0.0001);
}
saveRole(role: any): void {
this.savingKey = role.configKey;
this.errorMsg = '';
this.successMsg = '';
this.organizationService
.updateRbiReserveGlRole(
role.configKey,
(this.selection[role.configKey] || []).map((m) => ({
glAccountId: m.glAccountId,
weightPercentage: +m.weightPercentage,
// Sent as null off a liability head, so a class left over from a head that was retyped
// cannot travel with a figure the split does not apply to.
liabilityClass: role.classified ? m.liabilityClass : null
}))
)
.pipe(
catchError((err: any) => {
this.savingKey = null;
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.';
return of(null);
})
)
.subscribe((res: any) => {
this.savingKey = null;
if (!res) {
return;
}
this.applyConfig(res);
this.successMsg = `${this.roleLabel(role)} updated.`;
});
}
saveRate(): void {
if (this.newRate.percentage === null || !this.newRate.effectiveFrom) {
this.errorMsg = 'Enter a percentage and an effective date.';
return;
}
this.savingRate = true;
this.errorMsg = '';
this.successMsg = '';
this.organizationService
.upsertRbiReserveRate(this.newRate.rateType, this.newRate.percentage, this.newRate.effectiveFrom)
.pipe(
catchError((err: any) => {
this.savingRate = false;
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this rate.';
return of(null);
})
)
.subscribe((res: any) => {
this.savingRate = false;
if (!res) {
return;
}
this.applyConfig(res);
this.successMsg = `${this.newRate.rateType} rate saved.`;
this.newRate.percentage = null;
});
}
deleteRate(rate: any): void {
this.errorMsg = '';
this.successMsg = '';
this.organizationService
.deleteRbiReserveRate(rate.id)
.pipe(
catchError((err: any) => {
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not remove this rate.';
return of(null);
})
)
.subscribe((res: any) => {
if (!res) {
return;
}
this.applyConfig(res);
this.successMsg = 'Rate removed.';
});
}
/** The rate a return filed today would use — the latest one already in effect. */
currentRate(rateType: string): any | null {
const today = new Date().toISOString().slice(0, 10);
const inForce = this.rates.filter((r) => r.rateType === rateType && r.effectiveFrom <= today);
return inForce.length ? inForce[0] : null;
}
ratesFor(rateType: string): any[] {
return this.rates.filter((r) => r.rateType === rateType);
}
/** A future-dated rate is on record but not yet applied — worth saying so explicitly. */
isFuture(rate: any): boolean {
return rate.effectiveFrom > new Date().toISOString().slice(0, 10);
}
/**
* A role's display name and how the return uses it, both as the backend states them.
*
* Taken from the response rather than from a table here: a role only exists because code
* computes something from it, and that code is what knows what it means. A copy in the screen
* would leave a newly added role showing as a raw key.
*/
roleLabel(role: any): string {
return role?.label || role?.configKey || '';
}
/** Which return each role feeds, so the consequence of leaving one unmapped is visible. */
roleUsedBy(role: any): string {
return role?.usedBy || '';
}
glLabel(accountId: number): string {
const account = this.glAccountOptions.find((a) => a.id === accountId);
return account ? `${account.glCode} — ${account.name}` : `#${accountId}`;
}
// ── heads an administrator adds ──
/**
* Adds a head to this return. The bank's own chart decides what these are - a reserve, a tax
* payable, bills payable - so they are rows rather than a fixed list in code.
*/
addRole(): void {
if (!this.newRole.label.trim()) {
this.errorMsg = 'Give the head a name.';
return;
}
this.addingRole = true;
this.errorMsg = '';
this.successMsg = '';
this.organizationService
.createRbiReserveRole({
label: this.newRole.label.trim(),
description: this.newRole.description.trim(),
// Added to the return this screen configures, so a head cannot land on a form the
// administrator is not looking at.
group: this.group ?? this.activeGroup,
usage: this.newRole.usage
})
.pipe(
catchError((err: any) => {
this.addingRole = false;
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not add this head.';
return of(null);
})
)
.subscribe((res: any) => {
this.addingRole = false;
if (!res) {
return;
}
this.applyConfig(res);
this.successMsg = `${this.newRole.label.trim()} added. Map its GL accounts below.`;
this.newRole = { label: '', description: '', usage: 'LIABILITY' };
this.showAddRole = false;
});
}
/**
* Removes a head and everything mapped to it. Confirmed first: the figures it contributed leave
* the return with it, so a return filed after this will not agree with one filed before.
*/
deleteRole(role: any): void {
if (!confirm(`Remove "${this.roleLabel(role)}" and its mapped accounts? Returns will stop counting it.`)) {
return;
}
this.errorMsg = '';
this.successMsg = '';
this.organizationService
.deleteRbiReserveRole(role.roleId)
.pipe(
catchError((err: any) => {
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not remove this head.';
return of(null);
})
)
.subscribe((res: any) => {
if (!res) {
return;
}
this.applyConfig(res);
this.successMsg = 'Head removed.';
});
}
/** How a head is consumed, in words, for the screen to state next to it. */
usageLabel(usage: string): string {
return this.roleUsages.find((u) => u.value === usage)?.label ?? usage;
}
}
@@ -0,0 +1,211 @@
<!--
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="rrc-container">
<div class="rrc-header">
<button mat-stroked-button [routerLink]="['/organization']" class="rrc-back">
<i class="fas fa-arrow-left"></i> Organization
</button>
<h2 class="rrc-header__title">TDS Configuration</h2>
</div>
<p class="rrc-sub">
Thresholds, rates and statutory references for TDS on deposit interest, per financial year, and the GL account
deducted tax is credited to. The TDS reports, the Form 121 register and each customer's Form 121 tab compute from
these figures only — a year not set up here is a year they refuse to compute.
</p>
@if (errorMsg) {
<div class="rrc-alert rrc-alert--error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="rrc-alert rrc-alert--success">{{ successMsg }}</div>
}
@if (loading) {
<div class="rrc-loading">Loading…</div>
} @else {
<!-- ═══ GL ═══ -->
<div class="rrc-card">
<h3 class="rrc-card__title">GL account</h3>
<p class="rrc-card__hint">
Only postable liability accounts are listed. Nothing is preselected: the right account differs per chart of
accounts.
</p>
@for (role of glRoles; track role.configKey) {
<div class="rrc-role" [class.rrc-role--unmapped]="!role.glAccountId">
<div class="rrc-role__head">
<div>
<span class="rrc-role__name">{{ role.label }}</span>
<span class="rrc-key">{{ role.configKey }}</span>
</div>
@if (role.glAccountId) {
<span class="rrc-badge rrc-badge--mapped">{{ role.glCode }} · {{ role.glAccountName }}</span>
} @else {
<span class="rrc-badge rrc-badge--unmapped">Not mapped</span>
}
</div>
<p class="rrc-role__desc">{{ role.description }}</p>
<div class="tdc-gl">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="tdc-gl__field">
<mat-label>GL account</mat-label>
<mat-select [(ngModel)]="glSelection[role.configKey]" [disabled]="savingGl">
<mat-option [value]="null">— Not mapped —</mat-option>
@for (o of glAccountOptions; track o.id) {
<mat-option [value]="o.id">{{ o.glCode }} · {{ o.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<button mat-raised-button color="primary" [disabled]="savingGl || !isGlDirty(role)" (click)="saveGl(role)">
{{ savingGl ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
}
</div>
<!-- ═══ Financial years ═══ -->
<div class="rrc-card">
<h3 class="rrc-card__title">Financial years</h3>
<p class="rrc-card__hint">
One entry per year, because the Finance Act fixes TDS per year and a return for an earlier year must keep
quoting that year's figures. Add the next year before 01 April, or TDS screens stop computing on that date.
</p>
<div class="rrc-tabs" role="tablist">
@for (y of years; track y.fy) {
<button
type="button"
role="tab"
class="rrc-tab"
[class.rrc-tab--active]="!isNew && draft?.fy === y.fy"
(click)="select(y)"
>
FY {{ y.fy }}
@if (y.seedDefault) {
<span class="rrc-tab__badge">confirm</span>
}
</button>
}
@if (isNew) {
<button type="button" role="tab" class="rrc-tab rrc-tab--active">New year</button>
} @else {
<button type="button" class="rrc-tab" (click)="startNewYear()">
<i class="fas fa-plus"></i> Add financial year
</button>
}
</div>
@if (draft) {
@if (draft.seedDefault && !isNew) {
<div class="rrc-alert rrc-alert--warn">
These figures were carried over from the values previously built into the application. Check them against
the Finance Act for FY {{ draft.fy }} and save to confirm.
</div>
}
@if (isNew) {
<div class="tdc-grid">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Financial year</mat-label>
<input matInput [(ngModel)]="draft.fy" placeholder="2027-28" />
@if (!fyValid) {
<mat-hint>Format: 2027-28</mat-hint>
} @else if (fyTaken) {
<mat-hint>FY {{ draft.fy }} is already configured</mat-hint>
}
</mat-form-field>
</div>
}
<h4 class="tdc-section">Thresholds &amp; rates</h4>
<div class="tdc-grid">
@for (f of rateFields; track f.key) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ f.label }}</mat-label>
<input
matInput
type="number"
[min]="0"
[max]="f.kind === 'percent' ? 100 : null"
[step]="f.kind === 'percent' ? 0.0001 : 1"
[(ngModel)]="$any(draft)[f.key]"
/>
@if (f.hint) {
<mat-hint>{{ f.hint }}</mat-hint>
}
</mat-form-field>
}
</div>
@if (noPanBelowRate) {
<div class="rrc-alert rrc-alert--warn">The rate without PAN cannot be lower than the rate with PAN.</div>
}
<h4 class="tdc-section">Declaration (Form 121 / 15G / 15H)</h4>
<div class="tdc-grid">
@for (f of declarationFields; track f.key) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ f.label }}</mat-label>
@if (f.kind === 'select') {
<mat-select [(ngModel)]="$any(draft)[f.key]">
@for (o of f.options; track o.value) {
<mat-option [value]="o.value">{{ o.label }}</mat-option>
}
</mat-select>
} @else {
<input matInput type="number" [min]="0" [(ngModel)]="$any(draft)[f.key]" />
}
@if (f.hint) {
<mat-hint>{{ f.hint }}</mat-hint>
}
</mat-form-field>
}
</div>
<h4 class="tdc-section">Statutory references</h4>
<p class="rrc-card__hint">Printed on the reports, certificates and returns for this year.</p>
<div class="tdc-grid">
@for (f of statuteFields; track f.key) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ f.label }}</mat-label>
@if (f.kind === 'select') {
<mat-select [(ngModel)]="$any(draft)[f.key]">
@for (o of f.options; track o.value) {
<mat-option [value]="o.value">{{ o.label }}</mat-option>
}
</mat-select>
} @else {
<input matInput [(ngModel)]="$any(draft)[f.key]" />
}
</mat-form-field>
}
</div>
<div class="rrc-role__controls">
<button mat-raised-button color="primary" [disabled]="!canSave" (click)="save()">
{{ saving ? 'Saving…' : isNew ? 'Add FY ' + draft.fy : 'Save FY ' + draft.fy }}
</button>
@if (isNew) {
<button mat-button (click)="cancelNew()">Cancel</button>
} @else if (years.length > 1) {
<button mat-button class="rrc-remove" (click)="deleteYear()">
<i class="fas fa-trash"></i> Remove FY {{ draft.fy }}
</button>
}
@if (draft.updatedOn && !isNew) {
<span class="rrc-card__hint">Last saved {{ draft.updatedOn | slice: 0 : 16 }}</span>
}
</div>
} @else {
<div class="rrc-alert rrc-alert--warn">
No financial year is configured. Every TDS screen is unable to compute until one is added.
</div>
}
</div>
}
</div>
@@ -0,0 +1,34 @@
/**
* 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/.
*/
/* Layout on top of the RBI reserve configuration styles this screen shares. */
.tdc-section {
margin: 20px 0 10px;
font-size: 14px;
font-weight: 600;
}
.tdc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px 16px;
margin-bottom: 8px;
}
.tdc-gl {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
&__field {
flex: 1 1 320px;
max-width: 520px;
}
}
@@ -0,0 +1,288 @@
/**
* 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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { catchError, forkJoin, of } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import {
TdsConfigResponse,
TdsConfigService,
TdsFyConfigRecord,
TdsGlRole
} from 'app/letter-mgmt/certificates/form-15gh/tds-config.service';
/** One editable field of a financial year, as the form lays it out. */
interface FieldDef {
key: keyof TdsFyConfigRecord;
label: string;
kind: 'amount' | 'percent' | 'int' | 'text' | 'select';
hint?: string;
options?: { value: string; label: string }[];
}
/**
* TDS on deposit interest: the thresholds, rates and statutory references for each financial year,
* and the GL account deducted tax is credited to.
*
* The TDS reports, the Form 121 register and the customer's Form 121 tab all compute from what is
* saved here - nothing about the tax is compiled into the screens. A year missing here is a year
* those screens refuse to compute.
*/
@Component({
selector: 'mifosx-tds-config',
templateUrl: './tds-config.component.html',
styleUrls: [
'../rbi-reserve-config/rbi-reserve-config.component.scss',
'./tds-config.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class TdsConfigComponent implements OnInit {
private tdsConfig = inject(TdsConfigService);
loading = true;
saving = false;
savingGl = false;
errorMsg = '';
successMsg = '';
years: TdsFyConfigRecord[] = [];
glRoles: TdsGlRole[] = [];
glAccountOptions: { id: number; name: string; glCode: string }[] = [];
/** Year on screen, as a working copy - committed on Save. */
draft: TdsFyConfigRecord | null = null;
/** True while the draft is a year not yet saved. */
isNew = false;
newFy = '';
/** Draft GL selection per role. */
glSelection: Record<string, number | null> = {};
readonly rateFields: FieldDef[] = [
{ key: 'thresholdGeneral', label: 'Threshold – general (₹)', kind: 'amount', hint: 'Payee below 60' },
{
key: 'thresholdSenior',
label: 'Threshold – senior citizen (₹)',
kind: 'amount',
hint: '60 at any time in the year'
},
{ key: 'ratePanPercentage', label: 'Rate – valid PAN (%)', kind: 'percent' },
{ key: 'rateNoPanPercentage', label: 'Rate – PAN missing / invalid (%)', kind: 'percent' },
{ key: 'lateDeductionPercentage', label: 'Late deduction interest (% per month)', kind: 'percent' },
{ key: 'lateDepositPercentage', label: 'Late deposit interest (% per month)', kind: 'percent' },
{ key: 'roundTo', label: 'Round TDS to nearest (₹)', kind: 'int', hint: '1 = nearest rupee' }
];
readonly declarationFields: FieldDef[] = [
{
key: 'declarationForm',
label: 'Declaration form',
kind: 'select',
options: [
{ value: '121', label: 'Form 121' },
{ value: '15G', label: 'Form 15G / 15H' }
]
},
{ key: 'basicExemption', label: 'Basic exemption limit (₹)', kind: 'amount' },
{ key: 'nilTaxCeiling', label: 'Nil-tax income ceiling (₹)', kind: 'amount' },
{
key: 'declarationStatementFrequency',
label: 'Declaration statement',
kind: 'select',
options: [
{ value: 'MONTHLY', label: 'Monthly' },
{ value: 'QUARTERLY', label: 'Quarterly' }
]
},
{
key: 'declarationStatementDueDay',
label: 'Statement due on day',
kind: 'int',
hint: 'Of the following month / quarter'
}
];
readonly statuteFields: FieldDef[] = [
{
key: 'regime',
label: 'Governing Act',
kind: 'select',
options: [
{ value: 'ITA_2025', label: 'Income Tax Act, 2025' },
{ value: 'ITA_1961', label: 'Income-tax Act, 1961' }
]
},
{ key: 'interestTdsSection', label: 'Section charging TDS on interest', kind: 'text' },
{ key: 'thresholdSection', label: 'Threshold section', kind: 'text' },
{ key: 'noPanSection', label: 'No-PAN higher rate section', kind: 'text' },
{ key: 'defaultSection', label: 'Default / late interest section', kind: 'text' },
{ key: 'declarationSection', label: 'Declaration section', kind: 'text' },
{ key: 'declarationRule', label: 'Declaration rule', kind: 'text' },
{ key: 'tdsStatementForm', label: 'Quarterly TDS return', kind: 'text' },
{ key: 'tdsStatementRule', label: 'TDS return rule', kind: 'text' },
{ key: 'certificateForm', label: 'TDS certificate', kind: 'text' }
];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
forkJoin({
config: this.tdsConfig.load().pipe(catchError(() => of(null))),
template: this.tdsConfig.template().pipe(catchError(() => of(null)))
}).subscribe(({ config, template }) => {
this.loading = false;
if (!config) {
this.errorMsg = 'Could not load TDS configuration.';
return;
}
this.glAccountOptions = template?.glAccountOptions || [];
this.apply(config, this.draft?.fy);
});
}
private apply(config: TdsConfigResponse, keepFy?: string): void {
this.years = config.years || [];
this.glRoles = config.glRoles || [];
this.glSelection = {};
for (const role of this.glRoles) {
this.glSelection[role.configKey] = role.glAccountId;
}
const target = this.years.find((y) => y.fy === keepFy) ?? this.years[0] ?? null;
this.select(target);
}
select(year: TdsFyConfigRecord | null): void {
this.draft = year ? { ...year } : null;
this.isNew = false;
}
get unmappedGl(): TdsGlRole[] {
return this.glRoles.filter((r) => !r.glAccountId);
}
/** The year after the latest configured one, e.g. '2027-28' after '2026-27'. */
private nextFy(): string {
const latest = this.years[0]?.fy;
const start = latest ? Number(latest.slice(0, 4)) + 1 : new Date().getFullYear();
return `${start}-${String(start + 1).slice(2)}`;
}
/**
* Starts a new year from the latest one's figures. The law rarely changes all of them at once, so
* copying is the common case; nothing is saved until the administrator reviews and saves it.
*/
startNewYear(): void {
const base = this.years[0];
this.newFy = this.nextFy();
this.draft = base
? { ...base, id: undefined, fy: this.newFy, seedDefault: false, updatedOn: null }
: ({
fy: this.newFy,
regime: 'ITA_2025',
declarationForm: '121',
declarationStatementFrequency: 'MONTHLY',
roundTo: 1
} as TdsFyConfigRecord);
this.isNew = true;
this.successMsg = '';
}
cancelNew(): void {
this.select(this.years[0] ?? null);
}
get fyValid(): boolean {
const m = /^(\d{4})-(\d{2})$/.exec(this.draft?.fy ?? '');
return !!m && (Number(m[1]) + 1) % 100 === Number(m[2]);
}
get fyTaken(): boolean {
return this.isNew && this.years.some((y) => y.fy === this.draft?.fy);
}
/** The no-PAN rate is a penalty rate; below the ordinary rate it would reward a missing PAN. */
get noPanBelowRate(): boolean {
return !!this.draft && Number(this.draft.rateNoPanPercentage) < Number(this.draft.ratePanPercentage);
}
get canSave(): boolean {
return !!this.draft && this.fyValid && !this.fyTaken && !this.noPanBelowRate && !this.saving;
}
save(): void {
if (!this.draft || !this.canSave) {
return;
}
this.saving = true;
this.errorMsg = '';
this.successMsg = '';
const fy = this.draft.fy;
this.tdsConfig.saveYear(this.draft).subscribe({
next: (res) => {
this.saving = false;
this.apply(res, fy);
this.successMsg = `FY ${fy} saved. Every TDS screen now computes with these figures.`;
},
error: (err) => {
this.saving = false;
this.errorMsg = this.messageOf(err, `Could not save FY ${fy}.`);
}
});
}
deleteYear(): void {
if (!this.draft || this.isNew) {
return;
}
const fy = this.draft.fy;
if (!confirm(`Remove the TDS configuration for FY ${fy}? TDS screens will stop computing that year.`)) {
return;
}
this.tdsConfig.deleteYear(fy).subscribe({
next: (res) => {
this.apply(res);
this.successMsg = `FY ${fy} removed.`;
},
error: (err) => (this.errorMsg = this.messageOf(err, `Could not remove FY ${fy}.`))
});
}
saveGl(role: TdsGlRole): void {
this.savingGl = true;
this.errorMsg = '';
this.successMsg = '';
this.tdsConfig.saveGl(role.configKey, this.glSelection[role.configKey] ?? null).subscribe({
next: (res) => {
this.savingGl = false;
this.apply(res, this.draft?.fy);
this.successMsg = `${role.label} GL saved.`;
},
error: (err) => {
this.savingGl = false;
this.errorMsg = this.messageOf(err, `Could not save ${role.label} GL.`);
}
});
}
isGlDirty(role: TdsGlRole): boolean {
return (this.glSelection[role.configKey] ?? null) !== (role.glAccountId ?? null);
}
private messageOf(err: any, fallback: string): string {
return err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || fallback;
}
}
@@ -23,6 +23,7 @@ import { OrganizationService } from '../organization.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { PopoverService } from '../../configuration-wizard/popover/popover.service'; import { PopoverService } from '../../configuration-wizard/popover/popover.service';
import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service'; import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Dialog Components */ /** Custom Dialog Components */
import { NextStepDialogComponent } from '../../configuration-wizard/next-step-dialog/next-step-dialog.component'; import { NextStepDialogComponent } from '../../configuration-wizard/next-step-dialog/next-step-dialog.component';
@@ -53,6 +54,7 @@ export class WorkingDaysComponent implements OnInit, AfterViewInit {
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
private configurationWizardService = inject(ConfigurationWizardService); private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService); private popoverService = inject(PopoverService);
private alertService = inject(AlertService);
/** Working days form. */ /** Working days form. */
workingDaysForm: UntypedFormGroup; workingDaysForm: UntypedFormGroup;
@@ -152,7 +154,13 @@ export class WorkingDaysComponent implements OnInit, AfterViewInit {
} }
} }
workingDays.recurrence = recurrence; workingDays.recurrence = recurrence;
this.organizationService.updateWorkingDays(workingDays).subscribe((response) => { this.organizationService.updateWorkingDays(workingDays).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Working Days update has been submitted for approval.'
});
}
if (this.configurationWizardService.showDefineWorkingDays) { if (this.configurationWizardService.showDefineWorkingDays) {
this.configurationWizardService.showDefineWorkingDays = false; this.configurationWizardService.showDefineWorkingDays = false;
this.openNextStepDialog(); this.openNextStepDialog();
@@ -50,6 +50,7 @@
<div class="cp-tab-content"> <div class="cp-tab-content">
<mifosx-current-product-terms-step <mifosx-current-product-terms-step
[CurrentProductsTemplate]="currentProductsTemplate" [CurrentProductsTemplate]="currentProductsTemplate"
[allowOverdraft]="settingsForm.get('allowOverdraft')"
></mifosx-current-product-terms-step> ></mifosx-current-product-terms-step>
</div> </div>
</mat-tab> </mat-tab>

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