Compare commits

...
Author SHA1 Message Date
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
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
184 changed files with 11925 additions and 1424 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",
@@ -539,7 +539,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>
+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,
@@ -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];
@@ -1455,7 +1458,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 +1503,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 +1567,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 +1752,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>
+13 -1
View File
@@ -25,6 +25,18 @@ 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'];
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 +54,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);
} }
+32 -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">
@@ -1203,8 +1211,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 +2071,28 @@
<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>
<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>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Form 9 SLR Statutory Liquidity Ratio')"
routerLink="/rbi/form9"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="percentage" class="gl-icon" style="color: #b45309"></fa-icon> Form 9 — SLR (monthly)
</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;
}
}
} }
@@ -27,12 +27,22 @@ import { SettingsService } from 'app/settings/settings.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,7 +86,13 @@ 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);
@@ -93,6 +109,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 +132,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 +190,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 +338,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;
@@ -0,0 +1,194 @@
<!--
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>
@if (loadingAmount) {
<div class="pcd__loading">
<mat-spinner diameter="28"></mat-spinner>
<span>Calculating premature-closure amount...</span>
</div>
} @else {
@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 (data.preClosurePenalApplicable) {
<div class="pcd__preview-row">
<span class="pcd__preview-label"
>Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }})</span
>
<span class="pcd__preview-value">&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 | 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>
}
<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. Open one first, or choose an action that
pays out in cash instead.
</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('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,388 @@
/**
* 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;
}
}
.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,218 @@
/**
* 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 { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
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): pay out in cash, or transfer the pre-maturity balance to a savings 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
]
})
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 ACTION_ICONS: Record<number, string> = {
[PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave',
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet'
};
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'
};
dialogRef = inject<MatDialogRef<PrematureCloseDialogComponent>>(MatDialogRef);
data: {
accountId: number | string;
accountNo?: string;
depositAmount?: number;
nominalAnnualInterestRate?: number;
preClosurePenalApplicable?: boolean;
preClosurePenalInterest?: number;
preClosurePenalInterestOnType?: { value: string };
} = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
prematureCloseForm: UntypedFormGroup;
onAccountClosureOptions: any[] = [];
savingsAccountsData: any[] = [];
maturityAmount: 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
]
});
this.prematureCloseForm
.get('closedOnDate')
.valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date));
// 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);
}
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
};
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.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-account control to match what the chosen type needs. */
private onClosureTypeChange(id: number): void {
if (id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID) {
if (!this.prematureCloseForm.contains('toSavingsAccountId')) {
this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl(''));
}
} else {
this.prematureCloseForm.removeControl('toSavingsAccountId');
this.prematureCloseForm.removeControl('transferDescription');
}
}
/** 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);
}
/** 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: () => {
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. */
@@ -238,7 +239,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 +267,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 +298,34 @@ export class FixedDepositAccountViewComponent implements OnInit {
}); });
} }
/**
* 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
}
});
prematureCloseDialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
this.reload();
}
});
}
/** /**
* Deletes Fixed Deposits Account. * Deletes Fixed Deposits Account.
*/ */
@@ -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'
@@ -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 });
});
}
}
@@ -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 {}
@@ -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,350 @@
<!--
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">
<h3 class="rrc-card__title">GL accounts behind each figure</h3>
<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.
</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,481 @@
/**
* 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;
}
.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,491 @@
/**
* 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 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)' },
{ 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 {
const 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.group ? allRoles.filter((r: any) => r.group === this.group) : allRoles;
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,216 @@
<!--
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,275 @@
/**
* 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>
@@ -127,15 +127,11 @@ export class CurrentProductAccountingStepComponent implements OnInit {
private patchAccountingValues(mappings: any) { private patchAccountingValues(mappings: any) {
this.CurrentProductAccountingForm.patchValue({ this.CurrentProductAccountingForm.patchValue({
savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '',
savingsControlAccountId: mappings.savingsControlAccount?.id || '', savingsControlAccountId: mappings.savingsControlAccount?.id || '',
transfersInSuspenseAccountId: mappings.transfersInSuspenseAccount?.id || '',
interestOnSavingsAccountId: mappings.interestOnSavingsAccount?.id || '',
incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '', incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '',
incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '', incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '',
incomeFromInterestId: mappings.incomeFromInterest?.id || '', incomeFromInterestId: mappings.incomeFromInterest?.id || '',
overdraftPortfolioControlId: mappings.overdraftPortfolioControl?.id || '', overdraftPortfolioControlId: mappings.overdraftPortfolioControl?.id || '',
writeOffAccountId: mappings.writeOffAccount?.id || '',
feesReceivableAccountId: mappings.feeReceivableAccount?.id || '', feesReceivableAccountId: mappings.feeReceivableAccount?.id || '',
penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '', penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '',
interestPayableAccountId: mappings.interestPayableAccount?.id || '', interestPayableAccountId: mappings.interestPayableAccount?.id || '',
@@ -267,14 +263,16 @@ export class CurrentProductAccountingStepComponent implements OnInit {
}; };
if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) { if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) {
addControl('savingsReferenceAccountId', true); // A current account pays no interest, has no write-off and does not use a transfers suspense account, so
// Interest On Savings, Write Off and Transfers In Suspense are not mapped. The keys stay savings-named
// because the backend's GL mapping reads them under those names.
// No Current Reference either: a current account's cash side posts to the branch cash GL (Financial
// Activity: Cash at Main Vault / Teller), the same one the cashier screen uses.
removeControl('savingsReferenceAccountId');
addControl('savingsControlAccountId', true); addControl('savingsControlAccountId', true);
addControl('transfersInSuspenseAccountId', true);
addControl('interestOnSavingsAccountId', true);
addControl('incomeFromFeeAccountId', true); addControl('incomeFromFeeAccountId', true);
addControl('incomeFromPenaltyAccountId', true); addControl('incomeFromPenaltyAccountId', true);
addControl('incomeFromInterestId', true); addControl('incomeFromInterestId', true);
addControl('writeOffAccountId', true);
addControl('overdraftPortfolioControlId', false); addControl('overdraftPortfolioControlId', false);
if (this.accounting.isAccrualAccountingRuleId(accountingRule)) { if (this.accounting.isAccrualAccountingRuleId(accountingRule)) {
@@ -297,12 +295,9 @@ export class CurrentProductAccountingStepComponent implements OnInit {
} else { } else {
removeControl('savingsReferenceAccountId'); removeControl('savingsReferenceAccountId');
removeControl('savingsControlAccountId'); removeControl('savingsControlAccountId');
removeControl('transfersInSuspenseAccountId');
removeControl('interestOnSavingsAccountId');
removeControl('incomeFromFeeAccountId'); removeControl('incomeFromFeeAccountId');
removeControl('incomeFromPenaltyAccountId'); removeControl('incomeFromPenaltyAccountId');
removeControl('incomeFromInterestId'); removeControl('incomeFromInterestId');
removeControl('writeOffAccountId');
removeControl('overdraftPortfolioControlId'); removeControl('overdraftPortfolioControlId');
removeControl('feesReceivableAccountId'); removeControl('feesReceivableAccountId');
removeControl('penaltiesReceivableAccountId'); removeControl('penaltiesReceivableAccountId');
@@ -345,14 +340,6 @@ export class CurrentProductAccountingStepComponent implements OnInit {
pillLabel: 'Asset', pillLabel: 'Asset',
pillClass: 'green', pillClass: 'green',
rows: [ rows: [
{
key: 'savingsReferenceAccountId',
label: 'Savings Reference Account',
inputLabel: 'Savings reference account',
controlName: 'savingsReferenceAccountId',
options: this.assetAccountOptions,
required: true
},
{ {
key: 'overdraftPortfolioControlId', key: 'overdraftPortfolioControlId',
label: 'Overdraft Portfolio Control', label: 'Overdraft Portfolio Control',
@@ -396,20 +383,12 @@ export class CurrentProductAccountingStepComponent implements OnInit {
rows: [ rows: [
{ {
key: 'savingsControlAccountId', key: 'savingsControlAccountId',
label: 'Savings Control Account', label: 'Current Control Account',
inputLabel: 'Savings control account', inputLabel: 'Current control account',
controlName: 'savingsControlAccountId', controlName: 'savingsControlAccountId',
options: this.liabilityAccountOptions, options: this.liabilityAccountOptions,
required: true required: true
}, },
{
key: 'transfersInSuspenseAccountId',
label: 'Transfers In Suspense Account',
inputLabel: 'Transfers in suspense account',
controlName: 'transfersInSuspenseAccountId',
options: this.liabilityAccountOptions,
required: true
},
{ {
key: 'interestPayableAccountId', key: 'interestPayableAccountId',
label: 'Interest Payable Account', label: 'Interest Payable Account',
@@ -430,32 +409,8 @@ export class CurrentProductAccountingStepComponent implements OnInit {
otherMappingsControlName: 'liabilitiesOtherGlMappings', otherMappingsControlName: 'liabilitiesOtherGlMappings',
otherAccountOptions: this.liabilityAccountOptions otherAccountOptions: this.liabilityAccountOptions
}, },
{ // No Expenses section: a current account pays no interest and has no write-off, so it maps no expense GL.
key: 'expenses', // The expensesOtherGlMappings control is kept, so an older product's saved expense mappings survive an edit.
title: 'Expenses',
pillLabel: 'Expense',
pillClass: 'amber',
rows: [
{
key: 'interestOnSavingsAccountId',
label: 'Interest On Savings Account',
inputLabel: 'Interest on savings account',
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
},
{
key: 'writeOffAccountId',
label: 'Write Off Account',
inputLabel: 'Write off account',
controlName: 'writeOffAccountId',
options: this.expenseAccountOptions,
required: true
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
otherAccountOptions: this.expenseAccountOptions
},
{ {
key: 'income', key: 'income',
title: 'Income', title: 'Income',
@@ -162,29 +162,16 @@
}}</span> }}</span>
</div> </div>
<div class="flex-100 layout-row">
<span class="flex-40">Days in Year:</span>
<span class="flex-60">{{ CurrentProduct.interestCalculationDaysInYearType }} Days</span>
</div>
<div class="flex-100 layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">Auto Interest Post:</span> <span class="flex-40">Auto Interest Post:</span>
<span class="flex-60">{{ CurrentProduct.autoPost | yesNo }}</span> <span class="flex-60">{{ CurrentProduct.autoPost | yesNo }}</span>
</div> </div>
@if (
CurrentProduct.frequencyFixDay !== undefined &&
CurrentProduct.frequencyFixDay !== null &&
CurrentProduct.frequencyFixDay !== ''
) {
<div class="flex-100 layout-row">
<span class="flex-40">Frequency (Fix Day):</span>
<span class="flex-60">{{ CurrentProduct.frequencyFixDay }} Days</span>
</div>
}
@if (CurrentProduct.lastInterestDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Last Interest Date:</span>
<span class="flex-60">{{ CurrentProduct.lastInterestDate | dateFormat }}</span>
</div>
}
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Settings' | translate }}</h3> <h3 class="mat-h3 flex-100">{{ 'labels.inputs.Settings' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider> <mat-divider class="flex-100"></mat-divider>
@@ -282,39 +269,58 @@
</div> </div>
@if (CurrentProduct.allowOverdraft) { @if (CurrentProduct.allowOverdraft) {
@if (CurrentProduct.avgBalanceCalcPeriod) { <div class="flex-100 layout-row">
<div class="flex-100 layout-row"> <span class="flex-40">Maximum OD Limit:</span>
<span class="flex-40">Avg Balance Calculation Period:</span> <span class="flex-60">₹ {{ CurrentProduct.overdraftLimit | formatNumber }}</span>
<span class="flex-60">{{ CurrentProduct.avgBalanceCalcPeriod }}</span> </div>
</div>
}
@if (CurrentProduct.validForPeriod) { @if (CurrentProduct.odMinLimit) {
<div class="flex-100 layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">Valid for Period:</span> <span class="flex-40">Minimum OD Limit:</span>
<span class="flex-60">{{ CurrentProduct.validForPeriod }}</span> <span class="flex-60">₹ {{ CurrentProduct.odMinLimit | formatNumber }}</span>
</div>
}
@if (CurrentProduct.overdraftLimit !== undefined && CurrentProduct.overdraftLimit !== '') {
<div class="flex-100 layout-row">
<span class="flex-40">OD Limit %:</span>
<span class="flex-60">{{ CurrentProduct.overdraftLimit }}</span>
</div>
}
@if (
CurrentProduct.nominalAnnualInterestRateOverdraft !== undefined &&
CurrentProduct.nominalAnnualInterestRateOverdraft !== ''
) {
<div class="flex-100 layout-row">
<span class="flex-40">Interest on OD %:</span>
<span class="flex-60">{{ CurrentProduct.nominalAnnualInterestRateOverdraft }}</span>
</div> </div>
} }
<div class="flex-100 layout-row"> <div class="flex-100 layout-row">
<span class="flex-40">OD Account Closed with Zero Balance in Day Closing:</span> <span class="flex-40">OD Security Type:</span>
<span class="flex-60">{{ CurrentProduct.odSecurityType | titlecase }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Sanction Validity:</span>
<span class="flex-60">{{ CurrentProduct.validForPeriod }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Excess Drawing Allowed:</span>
<span class="flex-60">{{ CurrentProduct.odExcessAllowed | yesNo }}</span>
</div>
@if (CurrentProduct.odExcessAllowed) {
<div class="flex-100 layout-row">
<span class="flex-40">Maximum Excess:</span>
<span class="flex-60">{{ CurrentProduct.odExcessLimitPercent }} % of limit</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Penal Charge on Excess:</span>
<span class="flex-60">{{ CurrentProduct.odPenalRate }} % p.a.</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">OD Interest Rate:</span>
<span class="flex-60">{{ CurrentProduct.nominalAnnualInterestRateOverdraft }} % p.a.</span>
</div>
@if (CurrentProduct.minOverdraftForInterestCalculation) {
<div class="flex-100 layout-row">
<span class="flex-40">Minimum OD for Interest:</span>
<span class="flex-60">₹ {{ CurrentProduct.minOverdraftForInterestCalculation | formatNumber }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">End OD Once Fully Repaid:</span>
<span class="flex-60">{{ CurrentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}</span> <span class="flex-60">{{ CurrentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}</span>
</div> </div>
} }
@@ -453,15 +459,6 @@
<div class="spa-gl-section"> <div class="spa-gl-section">
<h5 class="mat-h5">Assets</h5> <h5 class="mat-h5">Assets</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Savings reference account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
CurrentProduct.savingsReferenceAccountId,
CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row"> <div class="spa-gl-row">
<span class="spa-gl-row__label">Overdraft portfolio control</span> <span class="spa-gl-row__label">Overdraft portfolio control</span>
<span class="spa-gl-row__value">{{ <span class="spa-gl-row__value">{{
@@ -503,7 +500,7 @@
<div class="spa-gl-section"> <div class="spa-gl-section">
<h5 class="mat-h5">Liabilities</h5> <h5 class="mat-h5">Liabilities</h5>
<div class="spa-gl-row"> <div class="spa-gl-row">
<span class="spa-gl-row__label">Savings control account</span> <span class="spa-gl-row__label">Current control account</span>
<span class="spa-gl-row__value">{{ <span class="spa-gl-row__value">{{
formatGlAccountById( formatGlAccountById(
CurrentProduct.savingsControlAccountId, CurrentProduct.savingsControlAccountId,
@@ -511,15 +508,6 @@
) )
}}</span> }}</span>
</div> </div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Transfers in suspense account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
CurrentProduct.transfersInSuspenseAccountId,
CurrentProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row"> <div class="spa-gl-row">
<span class="spa-gl-row__label">Interest payable account</span> <span class="spa-gl-row__label">Interest payable account</span>
<span class="spa-gl-row__value">{{ <span class="spa-gl-row__value">{{
@@ -542,28 +530,6 @@
} }
</div> </div>
<div class="spa-gl-section">
<h5 class="mat-h5">Expenses</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest on savings account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
CurrentProduct.interestOnSavingsAccountId,
CurrentProductsTemplate?.accountingMappingOptions.expenseAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Write off account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
CurrentProduct.writeOffAccountId,
CurrentProductsTemplate?.accountingMappingOptions.expenseAccountOptions
)
}}</span>
</div>
</div>
<div class="spa-gl-section"> <div class="spa-gl-section">
<h5 class="mat-h5">Income</h5> <h5 class="mat-h5">Income</h5>
<div class="spa-gl-row"> <div class="spa-gl-row">
@@ -110,72 +110,126 @@
<span>Overdraft (OD) Facility</span> <span>Overdraft (OD) Facility</span>
</div> </div>
<div class="sps-body"> <div class="sps-body">
<!-- "Is Overdraft Allowed" is ticked on the Terms tab; this section follows it. -->
@if (!CurrentProductSettingsForm.value.allowOverdraft) {
<p class="sps-od-check-hint">
Overdraft is not allowed on this product. Tick "Is Overdraft Allowed" on the Terms tab to set the OD limits.
</p>
}
<div class="sps-od-layout"> <div class="sps-od-layout">
<!-- Left: checkboxes --> <!-- Left: checkboxes -->
<div class="sps-od-checks"> <div class="sps-od-checks">
<div class="sps-od-check-row">
<mat-checkbox formControlName="allowOverdraft">
<span>Is Overdraft Allowed</span>
</mat-checkbox>
</div>
@if (CurrentProductSettingsForm.value.allowOverdraft) { @if (CurrentProductSettingsForm.value.allowOverdraft) {
<div class="sps-od-check-row"> <div class="sps-od-check-row">
<mat-checkbox formControlName="odAccountClosedZeroBalanceInDayClosing"> <mat-checkbox formControlName="odAccountClosedZeroBalanceInDayClosing">
<span>OD Account Closed with Zero Balance in Day Closing</span> <span>End OD Once Fully Repaid (One-time OD only)</span>
</mat-checkbox> </mat-checkbox>
<p class="sps-od-check-hint"> <p class="sps-od-check-hint">
Automatically closes OD accounts when balance reaches zero during day-end closing. At day-end, withdraws the OD sanction when the debit balance is cleared to zero. Only the OD ends, not
the account. Leave off for a regular OD: a customer who uses it and repays the same day would lose it.
</p> </p>
</div> </div>
} }
</div> </div>
<!-- Right: OD details panel — only visible when OD is allowed --> <!-- Right: the OD scheme every account's sanction is held to — only visible when OD is allowed -->
@if (CurrentProductSettingsForm.value.allowOverdraft) { @if (CurrentProductSettingsForm.value.allowOverdraft) {
<div class="sps-od-panel"> <div class="sps-od-panel">
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">Average Balance Calculation in Period</span>
<mat-radio-group formControlName="avgBalanceCalcPeriod" class="sps-od-radio-choices">
<mat-radio-button value="6M">6M</mat-radio-button>
<mat-radio-button value="1Y">1Y</mat-radio-button>
<mat-radio-button value="3Y">3Y</mat-radio-button>
</mat-radio-group>
</div>
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">Valid for Period</span>
<mat-radio-group formControlName="validForPeriod" class="sps-od-radio-choices">
<mat-radio-button value="3M">3M</mat-radio-button>
<mat-radio-button value="1Y">1Y</mat-radio-button>
<mat-radio-button value="3Y">3Y</mat-radio-button>
</mat-radio-group>
</div>
<div class="sps-od-inputs"> <div class="sps-od-inputs">
<mat-form-field appearance="outline" class="sps-field"> <mat-form-field appearance="outline" class="sps-field">
<mat-label>OD Limit %</mat-label> <mat-label>Maximum OD Limit (₹)</mat-label>
<input <input
type="number" type="number"
matInput matInput
formControlName="overdraftLimit" formControlName="overdraftLimit"
placeholder="e.g. 15" placeholder="e.g. 500000"
min="0" min="1"
step="0.01" required
/> />
<mat-hint>Highest per account</mat-hint>
<mat-error>Enter the maximum OD limit</mat-error>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="sps-field"> <mat-form-field appearance="outline" class="sps-field">
<mat-label>Interest on OD %</mat-label> <mat-label>Minimum OD Limit (₹)</mat-label>
<input <input type="number" matInput formControlName="odMinLimit" placeholder="e.g. 10000" min="0" />
type="number" <mat-hint>Lowest per account</mat-hint>
matInput @if (CurrentProductSettingsForm.get('odMinLimit')?.hasError('exceedsMax')) {
formControlName="nominalAnnualInterestRateOverdraft" <mat-error>Cannot exceed the maximum OD limit</mat-error>
placeholder="e.g. 8.5" } @else {
min="0" <mat-error>Cannot be negative</mat-error>
step="0.01" }
/>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">OD Security Type</span>
<mat-radio-group formControlName="odSecurityType" class="sps-od-radio-choices">
@for (option of odSecurityTypeOptions; track option.value) {
<mat-radio-button [value]="option.value">{{ option.label }}</mat-radio-button>
}
</mat-radio-group>
<p class="sps-od-check-hint">{{ selectedSecurityTypeHint }}</p>
</div>
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">Sanction Validity</span>
<mat-radio-group formControlName="validForPeriod" class="sps-od-radio-choices">
@for (option of odValidityOptions; track option.value) {
<mat-radio-button [value]="option.value">{{ option.label }}</mat-radio-button>
}
</mat-radio-group>
</div>
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">Excess Drawing (Over Limit)</span>
<mat-checkbox formControlName="odExcessAllowed">
<span>Allow withdrawal beyond the sanctioned limit</span>
</mat-checkbox>
@if (!CurrentProductSettingsForm.value.odExcessAllowed) {
<p class="sps-od-check-hint">Off: a withdrawal that would cross the limit is refused.</p>
} @else {
<div class="sps-od-inputs">
<mat-form-field appearance="outline" class="sps-field">
<mat-label>Maximum Excess (% of limit)</mat-label>
<input
type="number"
matInput
formControlName="odExcessLimitPercent"
placeholder="e.g. 10"
min="0"
step="0.01"
required
/>
<mat-hint>Beyond this, refused</mat-hint>
<mat-error>Enter above 0% and up to 25%</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>Penal Charge on Excess (% p.a.)</mat-label>
<input
type="number"
matInput
formControlName="odPenalRate"
placeholder="e.g. 2"
min="0"
step="0.01"
required
/>
<mat-hint>Only on the excess amount</mat-hint>
<mat-error>Enter above 0% and up to 50%</mat-error>
</mat-form-field>
</div>
@if (excessExample) {
<p class="sps-od-check-hint">{{ excessExample }}</p>
}
}
</div>
<p class="sps-od-check-hint">
Each account is sanctioned its own limit within this range, valid for the period above. The OD interest
rate is set on the Terms tab.
</p>
</div> </div>
} }
</div> </div>
@@ -7,7 +7,7 @@
*/ */
import { Component, OnInit, Input, inject } from '@angular/core'; import { Component, OnInit, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; import { AbstractControl, UntypedFormGroup, UntypedFormBuilder, ValidationErrors, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio'; import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -40,6 +40,21 @@ export class CurrentProductSettingsStepComponent implements OnInit {
{ id: 'YEARS', value: 'value.years' } { id: 'YEARS', value: 'value.years' }
]; ];
/** How long an OD sanction on this product runs before it must be renewed. */
odValidityOptions = [
{ value: '3M', label: '3 Months' },
{ value: '6M', label: '6 Months' },
{ value: '1Y', label: '1 Year' },
{ value: '2Y', label: '2 Years' },
{ value: '3Y', label: '3 Years' }
];
odSecurityTypeOptions = [
{ value: 'CLEAN', label: 'Clean', hint: 'No security needed' },
{ value: 'SECURED', label: 'Secured', hint: 'Against FD lien, property, etc.' },
{ value: 'BOTH', label: 'Both', hint: 'Decided at each sanction' }
];
constructor() { constructor() {
this.createCurrentProductSettingsForm(); this.createCurrentProductSettingsForm();
this.setConditionalControls(); this.setConditionalControls();
@@ -72,14 +87,15 @@ export class CurrentProductSettingsStepComponent implements OnInit {
daysToEscheat: isEditing daysToEscheat: isEditing
? this.CurrentProductsTemplate.daysToEscheat || DORMANCY_DEFAULTS.daysToEscheat ? this.CurrentProductsTemplate.daysToEscheat || DORMANCY_DEFAULTS.daysToEscheat
: DORMANCY_DEFAULTS.daysToEscheat, : DORMANCY_DEFAULTS.daysToEscheat,
// Overdraft fields // Overdraft scheme (the OD interest rate lives on the Terms tab)
allowOverdraft: isEditing ? (this.CurrentProductsTemplate.allowOverdraft ?? false) : false, allowOverdraft: isEditing ? (this.CurrentProductsTemplate.allowOverdraft ?? false) : false,
overdraftLimit: isEditing ? this.CurrentProductsTemplate.overdraftLimit : '', overdraftLimit: isEditing ? this.CurrentProductsTemplate.overdraftLimit : '',
nominalAnnualInterestRateOverdraft: isEditing odMinLimit: isEditing ? this.CurrentProductsTemplate.odMinLimit : '',
? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft odSecurityType: isEditing ? this.CurrentProductsTemplate.odSecurityType || 'CLEAN' : 'CLEAN',
: '', validForPeriod: this.validityOrDefault(isEditing ? this.CurrentProductsTemplate.validForPeriod : null),
avgBalanceCalcPeriod: isEditing ? this.CurrentProductsTemplate.avgBalanceCalcPeriod || '1Y' : '1Y', odExcessAllowed: isEditing ? !!this.CurrentProductsTemplate.odExcessAllowed : false,
validForPeriod: isEditing ? this.CurrentProductsTemplate.validForPeriod || '1Y' : '1Y', odExcessLimitPercent: isEditing ? this.CurrentProductsTemplate.odExcessLimitPercent : '',
odPenalRate: isEditing ? this.CurrentProductsTemplate.odPenalRate : '',
odAccountClosedZeroBalanceInDayClosing: isEditing odAccountClosedZeroBalanceInDayClosing: isEditing
? (this.CurrentProductsTemplate.odAccountClosedZeroBalanceInDayClosing ?? false) ? (this.CurrentProductsTemplate.odAccountClosedZeroBalanceInDayClosing ?? false)
: false : false
@@ -123,23 +139,123 @@ export class CurrentProductSettingsStepComponent implements OnInit {
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive], daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy], daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat], daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
// Overdraft (OD) Facility // Overdraft (OD) Facility: the scheme an account's OD sanction is held to
allowOverdraft: [false], allowOverdraft: [false],
overdraftLimit: [ overdraftLimit: [''],
'', odMinLimit: [''],
[Validators.min(0)] odSecurityType: ['CLEAN'],
],
nominalAnnualInterestRateOverdraft: [
'',
[Validators.min(0)]
],
avgBalanceCalcPeriod: ['1Y'],
validForPeriod: ['1Y'], validForPeriod: ['1Y'],
// Excess drawing past the sanctioned limit: off means it is refused
odExcessAllowed: [false],
odExcessLimitPercent: [''],
odPenalRate: [''],
odAccountClosedZeroBalanceInDayClosing: [false] odAccountClosedZeroBalanceInDayClosing: [false]
}); });
} }
get selectedSecurityTypeHint(): string {
const selected = this.CurrentProductSettingsForm.get('odSecurityType')?.value;
return this.odSecurityTypeOptions.find((option) => option.value === selected)?.hint ?? '';
}
/** A worked example on the maximum limit, so the percentages read as rupees. */
get excessExample(): string {
const value = this.CurrentProductSettingsForm.value;
const limit = Number(value.overdraftLimit);
const percent = Number(value.odExcessLimitPercent);
const penal = Number(value.odPenalRate);
if (!limit || !percent) return '';
const excess = (limit * percent) / 100;
const inr = (n: number) => '₹' + n.toLocaleString('en-IN', { maximumFractionDigits: 2 });
let text = `On a ${inr(limit)} limit, up to ${inr(excess)} more may be drawn.`;
if (penal) {
text += ` If ${inr(excess)} stays over the limit for a day, the penal charge is ${inr((excess * penal) / 100 / 365)}.`;
}
return text;
}
private validityOrDefault(value: string | null): string {
return this.odValidityOptions.some((option) => option.value === value) ? value : '1Y';
}
/** The minimum sanction may not exceed the maximum. */
private odMinWithinMax = (control: AbstractControl): ValidationErrors | null => {
const max = Number(this.CurrentProductSettingsForm?.get('overdraftLimit')?.value);
const min = Number(control.value);
if (control.value === '' || control.value === null || !max) return null;
return min > max ? { exceedsMax: true } : null;
};
private applyOverdraftValidators(allowed: boolean) {
const max = this.CurrentProductSettingsForm.get('overdraftLimit');
const min = this.CurrentProductSettingsForm.get('odMinLimit');
const security = this.CurrentProductSettingsForm.get('odSecurityType');
const validity = this.CurrentProductSettingsForm.get('validForPeriod');
if (allowed) {
max.setValidators([
Validators.required,
Validators.min(1)
]);
min.setValidators([
Validators.min(0),
this.odMinWithinMax
]);
security.setValidators([Validators.required]);
validity.setValidators([Validators.required]);
} else {
[
max,
min,
security,
validity
].forEach((control) => control.clearValidators());
}
[
max,
min,
security,
validity
].forEach((control) => control.updateValueAndValidity({ emitEvent: false }));
this.applyExcessValidators();
}
/** The excess cap and penal rate are required only while both OD and excess drawing are on. */
private applyExcessValidators() {
const form = this.CurrentProductSettingsForm;
const required = !!form.get('allowOverdraft').value && !!form.get('odExcessAllowed').value;
const percent = form.get('odExcessLimitPercent');
const penal = form.get('odPenalRate');
percent.setValidators(
required
? [
Validators.required,
Validators.min(0.01),
Validators.max(25)
]
: []
);
penal.setValidators(
required
? [
Validators.required,
Validators.min(0.01),
Validators.max(50)
]
: []
);
percent.updateValueAndValidity({ emitEvent: false });
penal.updateValueAndValidity({ emitEvent: false });
}
setConditionalControls() { setConditionalControls() {
this.CurrentProductSettingsForm.get('allowOverdraft').valueChanges.subscribe((allowed: boolean) =>
this.applyOverdraftValidators(!!allowed)
);
this.CurrentProductSettingsForm.get('odExcessAllowed').valueChanges.subscribe(() => this.applyExcessValidators());
this.CurrentProductSettingsForm.get('overdraftLimit').valueChanges.subscribe(() =>
this.CurrentProductSettingsForm.get('odMinLimit').updateValueAndValidity({ emitEvent: false })
);
this.CurrentProductSettingsForm.get('enableLockinPeriod').valueChanges.subscribe((enabled: boolean) => { this.CurrentProductSettingsForm.get('enableLockinPeriod').valueChanges.subscribe((enabled: boolean) => {
const freq = this.CurrentProductSettingsForm.get('lockinPeriodFrequency'); const freq = this.CurrentProductSettingsForm.get('lockinPeriodFrequency');
const type = this.CurrentProductSettingsForm.get('lockinPeriodFrequencyType'); const type = this.CurrentProductSettingsForm.get('lockinPeriodFrequencyType');
@@ -168,10 +284,15 @@ export class CurrentProductSettingsStepComponent implements OnInit {
if (!formValue.allowOverdraft) { if (!formValue.allowOverdraft) {
delete formValue.overdraftLimit; delete formValue.overdraftLimit;
delete formValue.nominalAnnualInterestRateOverdraft; delete formValue.odMinLimit;
delete formValue.avgBalanceCalcPeriod; delete formValue.odSecurityType;
delete formValue.validForPeriod; delete formValue.validForPeriod;
delete formValue.odAccountClosedZeroBalanceInDayClosing; delete formValue.odAccountClosedZeroBalanceInDayClosing;
delete formValue.odExcessAllowed;
}
if (!formValue.allowOverdraft || !formValue.odExcessAllowed) {
delete formValue.odExcessLimitPercent;
delete formValue.odPenalRate;
} }
return formValue; return formValue;
@@ -13,20 +13,76 @@
<span>Interest On OD</span> <span>Interest On OD</span>
</div> </div>
<div class="spt-body"> <div class="spt-body">
<mat-form-field appearance="outline" class="spt-field" style="max-width: 320px"> @if (allowOverdraftControl) {
<mat-label>Interest Posting Frequency *</mat-label> <div class="spt-toggle-card">
<mat-select formControlName="interestPostingPeriodType" required> <mat-checkbox [formControl]="allowOverdraftControl">
@for (item of interestPostingPeriodTypeData; track item) { <span>Is Overdraft Allowed</span>
<mat-option [value]="item.id"> </mat-checkbox>
{{ item.value | translateKey: 'catalogs' }} </div>
</mat-option> }
} @if (!isOverdraftAllowed) {
</mat-select> <div class="spt-note">
<mat-error> <span>ℹ️</span>
Interest Posting Frequency {{ 'labels.commons.is' | translate }} <span>Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab.</span>
<strong>{{ 'labels.commons.required' | translate }}</strong> </div>
</mat-error> }
</mat-form-field>
<div class="spt-footer__grid">
@if (isOverdraftAllowed) {
<mat-form-field appearance="outline" class="spt-field">
<mat-label>OD Interest Rate (% p.a.)</mat-label>
<input
type="number"
matInput
formControlName="nominalAnnualInterestRateOverdraft"
placeholder="e.g. 12.5"
min="0"
step="0.01"
required
/>
<mat-hint>Charged on the debit balance</mat-hint>
<mat-error>Enter a rate above 0% and up to 50%</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Minimum OD for Interest (₹)</mat-label>
<input
type="number"
matInput
formControlName="minOverdraftForInterestCalculation"
placeholder="e.g. 100"
min="0"
/>
<mat-hint>No interest while the debit balance is below this</mat-hint>
<mat-error>Cannot be negative</mat-error>
</mat-form-field>
}
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Interest Posting Frequency</mat-label>
<mat-select formControlName="interestPostingPeriodType" required>
@for (item of interestPostingPeriodTypeData; track item.id) {
<mat-option [value]="item.id">
{{ item.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
<mat-hint>Monthly, as RBI requires for advances</mat-hint>
<mat-error>
Interest Posting Frequency {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Days in Year</mat-label>
<mat-select formControlName="interestCalculationDaysInYearType">
@for (item of daysInYearData; track item.id) {
<mat-option [value]="item.id">{{ item.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="spt-footer"> <div class="spt-footer">
<div class="spt-footer__grid"> <div class="spt-footer__grid">
@@ -43,25 +99,17 @@
} }
</div> </div>
</div> </div>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Frequency (Fix Day)</mat-label>
<input type="number" matInput formControlName="frequencyFixDay" />
<mat-hint>Days</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Last Interest Date</mat-label>
<input matInput [matDatepicker]="lastInterestDatePicker" formControlName="lastInterestDate" />
<mat-datepicker-toggle matSuffix [for]="lastInterestDatePicker"></mat-datepicker-toggle>
<mat-datepicker #lastInterestDatePicker></mat-datepicker>
</mat-form-field>
</div> </div>
<div class="spt-note"> @if (isOverdraftAllowed) {
<span>⚠️</span> <div class="spt-note">
<span>Daily product-based interest accrual enabled — interest calculated EOD on daily closing balance.</span> <span>ℹ️</span>
</div> <span>
Rule: daily OD interest = end-of-day debit balance × rate ÷ days in year, applied to the account at the
posting frequency.
</span>
</div>
}
</div> </div>
</div> </div>
</div> </div>
@@ -7,12 +7,21 @@
*/ */
import { Component, OnInit, Input, inject } from '@angular/core'; import { Component, OnInit, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; import { AbstractControl, UntypedFormControl, UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Posting-period ids an OD's interest may be applied on: RBI requires monthly for advances; quarterly is the outer limit. */
const OD_POSTING_PERIOD_IDS = [
4,
5
];
/**
* Interest on OD: the rate and rules an overdraft on this product is charged by. Interest is worked out daily on the
* end-of-day debit balance and applied to the account at the posting frequency. The fields apply only when the
* Settings tab allows an overdraft, so allowOverdraft (that tab's control) switches their validators on and off.
*/
@Component({ @Component({
selector: 'mifosx-current-product-terms-step', selector: 'mifosx-current-product-terms-step',
templateUrl: './current-product-terms-step.component.html', templateUrl: './current-product-terms-step.component.html',
@@ -24,14 +33,18 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
}) })
export class CurrentProductTermsStepComponent implements OnInit { export class CurrentProductTermsStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder); private formBuilder = inject(UntypedFormBuilder);
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
@Input() CurrentProductsTemplate: any; @Input() CurrentProductsTemplate: any;
/** The Settings tab's allowOverdraft control. */
@Input() allowOverdraft: AbstractControl;
CurrentProductTermsForm: UntypedFormGroup; CurrentProductTermsForm: UntypedFormGroup;
interestPostingPeriodTypeData: any[] = []; interestPostingPeriodTypeData: any[] = [];
daysInYearData: Array<{ id: number; label: string }> = [
{ id: 365, label: '365 Days (Actual)' },
{ id: 360, label: '360 Days' }
];
yesNoOptions: Array<{ value: boolean; label: string }> = [ yesNoOptions: Array<{ value: boolean; label: string }> = [
{ value: true, label: 'Yes' }, { value: true, label: 'Yes' },
{ value: false, label: 'No' } { value: false, label: 'No' }
@@ -41,29 +54,59 @@ export class CurrentProductTermsStepComponent implements OnInit {
this.createCurrentProductTermsForm(); this.createCurrentProductTermsForm();
} }
get isOverdraftAllowed(): boolean {
return !!this.allowOverdraft?.value;
}
/**
* The Settings tab's own control, so the box can be ticked here without going to that tab first; the two
* checkboxes are one control and always agree.
*/
get allowOverdraftControl(): UntypedFormControl | null {
return this.allowOverdraft instanceof UntypedFormControl ? this.allowOverdraft : null;
}
ngOnInit() { ngOnInit() {
this.interestPostingPeriodTypeData = this.CurrentProductsTemplate.interestPostingPeriodTypeOptions || []; const postingOptions: any[] = this.CurrentProductsTemplate.interestPostingPeriodTypeOptions || [];
const odPostingOptions = postingOptions.filter((option: any) => OD_POSTING_PERIOD_IDS.includes(option.id));
this.interestPostingPeriodTypeData = odPostingOptions.length ? odPostingOptions : postingOptions;
this.setYesNoOptions(this.CurrentProductsTemplate?.yesNoOptions); this.setYesNoOptions(this.CurrentProductsTemplate?.yesNoOptions);
const isEditing = !!this.CurrentProductsTemplate.id; const isEditing = !!this.CurrentProductsTemplate.id;
const defaultPosting =
this.interestPostingPeriodTypeData.find((option: any) => option.id === 4)?.id ??
this.interestPostingPeriodTypeData[0]?.id ??
'';
const storedPosting = this.CurrentProductsTemplate.interestPostingPeriodType?.id;
const storedDaysInYear = this.CurrentProductsTemplate.interestCalculationDaysInYearType?.id;
const storedPostingOffered = this.interestPostingPeriodTypeData.some((option: any) => option.id === storedPosting);
this.CurrentProductTermsForm.patchValue({ this.CurrentProductTermsForm.patchValue({
interestPostingPeriodType: this.CurrentProductsTemplate.interestPostingPeriodType?.id || '', interestPostingPeriodType: storedPostingOffered ? storedPosting : defaultPosting,
autoPost: isEditing ? (this.CurrentProductsTemplate.autoPost ?? true) : true, interestCalculationDaysInYearType: storedDaysInYear === 360 ? 360 : 365,
frequencyFixDay: isEditing ? (this.CurrentProductsTemplate.frequencyFixDay ?? 30) : 30, nominalAnnualInterestRateOverdraft: isEditing ? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft : '',
lastInterestDate: this.parseDate(this.CurrentProductsTemplate.lastInterestDate) minOverdraftForInterestCalculation: isEditing ? this.CurrentProductsTemplate.minOverdraftForInterestCalculation : '',
autoPost: isEditing ? (this.CurrentProductsTemplate.autoPost ?? true) : true
}); });
// The Settings tab patches allowOverdraft in its own ngOnInit, after this one, so follow its changes as well as
// its current value.
this.applyOverdraftValidators(this.isOverdraftAllowed);
this.allowOverdraft?.valueChanges.subscribe((allowed: boolean) => this.applyOverdraftValidators(!!allowed));
} }
private parseDate(dateValue: any): Date | null { private applyOverdraftValidators(allowed: boolean) {
if (!dateValue) return null; const rate = this.CurrentProductTermsForm.get('nominalAnnualInterestRateOverdraft');
if (Array.isArray(dateValue)) { rate.setValidators(
return new Date(dateValue[0], dateValue[1] - 1, dateValue[2]); allowed
} ? [
if (dateValue instanceof Date) { Validators.required,
return dateValue; Validators.min(0.01),
} Validators.max(50)
return new Date(dateValue); ]
: [Validators.min(0)]
);
rate.updateValueAndValidity({ emitEvent: false });
} }
private setYesNoOptions(options: any[]) { private setYesNoOptions(options: any[]) {
@@ -90,23 +133,25 @@ export class CurrentProductTermsStepComponent implements OnInit {
'', '',
Validators.required Validators.required
], ],
autoPost: [true], interestCalculationDaysInYearType: [365],
frequencyFixDay: [ nominalAnnualInterestRateOverdraft: [
30, '',
[Validators.min(0)] [Validators.min(0)]
], ],
lastInterestDate: [''] minOverdraftForInterestCalculation: [
'',
[Validators.min(0)]
],
autoPost: [true]
}); });
} }
get CurrentProductTerms() { get CurrentProductTerms() {
const formValue = { ...this.CurrentProductTermsForm.value }; const formValue = { ...this.CurrentProductTermsForm.value };
const dateFormat = this.settingsService.dateFormat; if (!this.isOverdraftAllowed) {
delete formValue.nominalAnnualInterestRateOverdraft;
if (formValue.lastInterestDate instanceof Date) { delete formValue.minOverdraftForInterestCalculation;
formValue.lastInterestDate = this.dateUtils.formatDate(formValue.lastInterestDate, dateFormat) || '';
} }
return formValue;
return { ...formValue };
} }
} }
@@ -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]="currentProductAndTemplate" [CurrentProductsTemplate]="currentProductAndTemplate"
[allowOverdraft]="settingsForm.get('allowOverdraft')"
></mifosx-current-product-terms-step> ></mifosx-current-product-terms-step>
</div> </div>
</mat-tab> </mat-tab>
@@ -257,16 +257,10 @@
</span> </span>
</div> </div>
} }
@if (currentProduct.frequencyFixDay) { @if (currentProduct.interestCalculationDaysInYearType?.id) {
<div class="vsp-kv"> <div class="vsp-kv">
<span class="vsp-k">Frequency Fix Day</span> <span class="vsp-k">Days in Year</span>
<span class="vsp-v">{{ currentProduct.frequencyFixDay }}</span> <span class="vsp-v">{{ currentProduct.interestCalculationDaysInYearType.id }} Days</span>
</div>
}
@if (currentProduct.lastInterestDate) {
<div class="vsp-kv">
<span class="vsp-k">Last Interest Date</span>
<span class="vsp-v">{{ currentProduct.lastInterestDate | dateFormat }}</span>
</div> </div>
} }
</div> </div>
@@ -396,37 +390,57 @@
<span class="vsp-k">{{ <span class="vsp-k">{{
'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate
}}</span> }}</span>
<span class="vsp-v">{{ currentProduct.minOverdraftForInterestCalculation | formatNumber }}</span> <span class="vsp-v">₹ {{ currentProduct.minOverdraftForInterestCalculation | formatNumber }}</span>
</div> </div>
} }
@if (currentProduct.nominalAnnualInterestRateOverdraft) { @if (currentProduct.nominalAnnualInterestRateOverdraft) {
<div class="vsp-kv"> <div class="vsp-kv">
<span class="vsp-k">{{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}</span> <span class="vsp-k">{{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}</span>
<span class="vsp-v">{{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} %</span> <span class="vsp-v">{{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} % p.a.</span>
</div> </div>
} }
@if (currentProduct.overdraftLimit) { @if (currentProduct.overdraftLimit) {
<div class="vsp-kv"> <div class="vsp-kv">
<span class="vsp-k">{{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}</span> <span class="vsp-k">{{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}</span>
<span class="vsp-v">{{ currentProduct.overdraftLimit | formatNumber }}</span> <span class="vsp-v">₹ {{ currentProduct.overdraftLimit | formatNumber }}</span>
</div> </div>
} }
@if (currentProduct.allowOverdraft) { @if (currentProduct.allowOverdraft) {
@if (currentProduct.avgBalanceCalcPeriod) { @if (currentProduct.odMinLimit) {
<div class="vsp-kv"> <div class="vsp-kv">
<span class="vsp-k">Average Balance Calculation Period</span> <span class="vsp-k">Minimum OD Limit</span>
<span class="vsp-v">{{ currentProduct.avgBalanceCalcPeriod }}</span> <span class="vsp-v">₹ {{ currentProduct.odMinLimit | formatNumber }}</span>
</div>
}
@if (currentProduct.odSecurityType) {
<div class="vsp-kv">
<span class="vsp-k">OD Security Type</span>
<span class="vsp-v">{{ currentProduct.odSecurityType | titlecase }}</span>
</div> </div>
} }
@if (currentProduct.validForPeriod) { @if (currentProduct.validForPeriod) {
<div class="vsp-kv"> <div class="vsp-kv">
<span class="vsp-k">Valid for Period</span> <span class="vsp-k">Sanction Validity</span>
<span class="vsp-v">{{ currentProduct.validForPeriod }}</span> <span class="vsp-v">{{ currentProduct.validForPeriod }}</span>
</div> </div>
} }
<div class="vsp-kv">
<span class="vsp-k">Excess Drawing Allowed</span>
<span class="vsp-v">{{ currentProduct.odExcessAllowed | yesNo }}</span>
</div>
@if (currentProduct.odExcessAllowed) {
<div class="vsp-kv">
<span class="vsp-k">Maximum Excess</span>
<span class="vsp-v">{{ currentProduct.odExcessLimitPercent | formatNumber }} % of limit</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Penal Charge on Excess</span>
<span class="vsp-v">{{ currentProduct.odPenalRate | formatNumber }} % p.a.</span>
</div>
}
@if (currentProduct.odAccountClosedZeroBalanceInDayClosing !== undefined) { @if (currentProduct.odAccountClosedZeroBalanceInDayClosing !== undefined) {
<div class="vsp-kv"> <div class="vsp-kv">
<span class="vsp-k">OD Account Closed with Zero Balance in Day Closing</span> <span class="vsp-k">End OD Once Fully Repaid</span>
<span class="vsp-v">{{ currentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}</span> <span class="vsp-v">{{ currentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}</span>
</div> </div>
} }
@@ -588,10 +602,34 @@
<div class="vsp-section-ic vsp-section-ic--green"><fa-icon icon="book"></fa-icon></div> <div class="vsp-section-ic vsp-section-ic--green"><fa-icon icon="book"></fa-icon></div>
<h3>{{ 'labels.heading.Accounting' | translate }}</h3> <h3>{{ 'labels.heading.Accounting' | translate }}</h3>
</div> </div>
<mifosx-view-savings-accounting-details <!--
[accountingRule]="currentProduct.accountingRule" The current product's own mappings only, under current-account names. The shared savings view is not used:
[accountingMappings]="currentProduct.accountingMappings" it labels everything as savings and lists savings-only GLs (Saving reference, Interest on savings...), and
></mifosx-view-savings-accounting-details> while current and savings products share mapping rows by id it would show a savings product's GLs here.
There is no cash (reference) GL: a current account's cash side posts to the branch cash GL.
-->
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Type</span>
<span class="vsp-v">{{ currentProduct.accountingRule?.value || 'Cash' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Cash GL (deposit / withdrawal)</span>
<span class="vsp-v">Branch cash GL — Financial Activity: Cash at Main Vault / Teller</span>
</div>
@for (gl of currentAccountingRows; track gl.label) {
<div class="vsp-kv">
<span class="vsp-k">{{ gl.label }}</span>
<span class="vsp-v">
@if (gl.account) {
({{ gl.account.glCode }}) {{ gl.account.name }}
} @else {
—
}
</span>
</div>
}
</div>
</section> </section>
<!-- OTHER GL MAPPINGS --> <!-- OTHER GL MAPPINGS -->
@@ -22,7 +22,6 @@ import {
MatRowDef, MatRowDef,
MatRow MatRow
} from '@angular/material/table'; } from '@angular/material/table';
import { ViewSavingsAccountingDetailsComponent } from '../../../../shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component';
import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe'; import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { YesnoPipe } from '../../../../pipes/yesno.pipe'; import { YesnoPipe } from '../../../../pipes/yesno.pipe';
@@ -47,7 +46,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow, MatHeaderRow,
MatRowDef, MatRowDef,
MatRow, MatRow,
ViewSavingsAccountingDetailsComponent,
DateFormatPipe, DateFormatPipe,
FormatNumberPipe, FormatNumberPipe,
YesnoPipe YesnoPipe
@@ -102,4 +100,20 @@ export class CurrentProductGeneralTabComponent {
isCashOrAccrualAccounting(): boolean { isCashOrAccrualAccounting(): boolean {
return this.accounting.isCashOrAccrualAccounting(this.currentProduct.accountingRule); return this.accounting.isCashOrAccrualAccounting(this.currentProduct.accountingRule);
} }
/** The GLs a current product maps, by the names the Accounting tab gives them. */
get currentAccountingRows(): Array<{ label: string; account: any }> {
const m = this.currentProduct?.accountingMappings || {};
const rows = [
{ label: 'Current Control (Liability)', account: m.savingsControlAccount },
{ label: 'Overdraft Portfolio Control (Asset)', account: m.overdraftPortfolioControl },
{ label: 'Income From Fees', account: m.incomeFromFeeAccount },
{ label: 'Income From Penalties', account: m.incomeFromPenaltyAccount },
{ label: 'Income From Interest', account: m.incomeFromInterest }
];
if (this.currentProduct?.isDormancyTrackingActive) {
rows.push({ label: 'Escheat Liability', account: m.escheatLiabilityAccount });
}
return rows;
}
} }
@@ -308,6 +308,11 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
setRequired('incomeFromPenaltyAccountId', cashOrAccrual); setRequired('incomeFromPenaltyAccountId', cashOrAccrual);
setRequired('feesReceivableAccountId', accrualOnly); setRequired('feesReceivableAccountId', accrualOnly);
setRequired('penaltiesReceivableAccountId', accrualOnly); setRequired('penaltiesReceivableAccountId', accrualOnly);
// Required only on accrual, where there is no posting without it. On cash it stays optional but
// is offered, because interest posting routes through it when the product names one (see
// AccountingProcessorHelper.createInterestPostingJournalEntriesForSavings): the payable carries
// interest declared but not yet credited, and a product that names none collapses straight to
// Savings Control instead.
setRequired('interestPayableAccountId', accrualOnly); setRequired('interestPayableAccountId', accrualOnly);
setRequired('escheatLiabilityId', cashOrAccrual && this.isDormancyTrackingEnabled()); setRequired('escheatLiabilityId', cashOrAccrual && this.isDormancyTrackingEnabled());
@@ -326,8 +331,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
rows: [ rows: [
{ {
key: 'savingsReferenceAccountId', key: 'savingsReferenceAccountId',
label: 'Savings Reference Account', label: 'Fixed Deposit Reference Account',
inputLabel: 'Savings reference account', inputLabel: 'Fixed deposit reference account',
controlName: 'savingsReferenceAccountId', controlName: 'savingsReferenceAccountId',
options: this.assetAccountOptions, options: this.assetAccountOptions,
required: true required: true
@@ -360,8 +365,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
rows: [ rows: [
{ {
key: 'savingsControlAccountId', key: 'savingsControlAccountId',
label: 'Savings Control Account', label: 'Fixed Deposit Control Account',
inputLabel: 'Savings control account', inputLabel: 'Fixed deposit control account',
controlName: 'savingsControlAccountId', controlName: 'savingsControlAccountId',
options: this.liabilityAccountOptions, options: this.liabilityAccountOptions,
required: true required: true
@@ -375,12 +380,16 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
required: true required: true
}, },
{ {
// Offered on cash accounting too, not only accrual: interest posting routes through
// this account whichever basis the product uses, so that interest declared but not yet
// credited to the member reads as the bank's own liability rather than appearing only
// when it is paid. Optional on cash - a product naming none posts straight to Savings
// Control as before.
key: 'interestPayableAccountId', key: 'interestPayableAccountId',
label: 'Interest Payable Account', label: 'Interest Payable Account',
inputLabel: 'Interest payable account', inputLabel: 'Interest payable account',
controlName: 'interestPayableAccountId', controlName: 'interestPayableAccountId',
options: this.liabilityAccountOptions, options: this.liabilityAccountOptions
accrualOnly: true
}, },
{ {
key: 'escheatLiabilityId', key: 'escheatLiabilityId',
@@ -402,8 +411,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
rows: [ rows: [
{ {
key: 'interestOnSavingsAccountId', key: 'interestOnSavingsAccountId',
label: 'Interest On Savings Account', label: 'Interest On Fixed Deposits',
inputLabel: 'Interest on savings account', inputLabel: 'Interest on fixed deposits',
controlName: 'interestOnSavingsAccountId', controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions, options: this.expenseAccountOptions,
required: true required: true
@@ -481,6 +481,7 @@
<mifosx-view-savings-accounting-details <mifosx-view-savings-accounting-details
[accountingRule]="accountingRule" [accountingRule]="accountingRule"
[accountingMappings]="accountingMappings" [accountingMappings]="accountingMappings"
productLabel="Fixed deposit"
></mifosx-view-savings-accounting-details> ></mifosx-view-savings-accounting-details>
} }
@@ -34,6 +34,35 @@
</div> </div>
</div> </div>
<!-- Renewal -->
<div class="fdpt-section">
<div class="fdpt-section__header">
<span class="fdpt-section__badge"><i class="fas fa-sync-alt"></i></span>
<span>Renewal</span>
</div>
<div class="fdpt-body">
<div class="fdpt-banner">
<span class="fdpt-banner__icon">ℹ️</span>
<span>
Leave blank for an FD on this product to keep renewing instantly, the same night it matures, as before. Set a
number of days and a matured "auto renew" FD instead waits on the FD Dashboard for that many days for staff to
renew it manually -- backdated to the original maturity date within that window, or from the day it is
actually renewed (with gap-period interest at the linked Savings account's rate) once the window has passed.
</span>
</div>
<div class="fdpt-grid fdpt-grid--three">
<mat-form-field appearance="outline" class="fdpt-field">
<mat-label>Renewal Grace Period</mat-label>
<input type="number" matInput formControlName="renewalGracePeriodDays" min="0" />
<mat-hint>Days</mat-hint>
@if (fixedDepositProductTermsForm.get('renewalGracePeriodDays').hasError('min')) {
<mat-error>Renewal Grace Period must be zero or greater</mat-error>
}
</mat-form-field>
</div>
</div>
</div>
<!-- Interest Rate & Posting Terms --> <!-- Interest Rate & Posting Terms -->
<div class="fdpt-section"> <div class="fdpt-section">
<div class="fdpt-section__header"> <div class="fdpt-section__header">
@@ -85,12 +85,21 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
: (this.interestCalculationTypeOptionData?.[0]?.id ?? null); : (this.interestCalculationTypeOptionData?.[0]?.id ?? null);
this.fixedDepositProductTermsForm.patchValue({ this.fixedDepositProductTermsForm.patchValue({
minDepositTerm: this.fixedDepositProductsTemplate.minDepositTerm ?? 1, // Hidden, unedited-by-anyone fields. On EDIT, left as the product's own saved value with no
minDepositTermTypeId: this.fixedDepositProductsTemplate.minDepositTermType?.id ?? defaultPeriodTypeId, // forced fallback (like their max-/in-multiples-of- siblings already correctly are), or a
// checker's "Proposed changes" review showed these as changed on every unrelated edit, never
// anything a maker touched. On CREATE there is no saved value yet, and the backend requires
// minDepositTerm -- default it (and its unit) here same as before, since the field is hidden
// and no one gets a chance to fill it in themselves.
minDepositTerm: isEditing ? (this.fixedDepositProductsTemplate.minDepositTerm ?? '') : 1,
minDepositTermTypeId: isEditing
? (this.fixedDepositProductsTemplate.minDepositTermType?.id ?? '')
: defaultPeriodTypeId,
maxDepositTerm: this.fixedDepositProductsTemplate.maxDepositTerm ?? '', maxDepositTerm: this.fixedDepositProductsTemplate.maxDepositTerm ?? '',
maxDepositTermTypeId: this.fixedDepositProductsTemplate.maxDepositTermType?.id ?? '', maxDepositTermTypeId: this.fixedDepositProductsTemplate.maxDepositTermType?.id ?? '',
inMultiplesOfDepositTerm: this.fixedDepositProductsTemplate.inMultiplesOfDepositTerm ?? '', inMultiplesOfDepositTerm: this.fixedDepositProductsTemplate.inMultiplesOfDepositTerm ?? '',
inMultiplesOfDepositTermTypeId: this.fixedDepositProductsTemplate.inMultiplesOfDepositTermType?.id ?? '', inMultiplesOfDepositTermTypeId: this.fixedDepositProductsTemplate.inMultiplesOfDepositTermType?.id ?? '',
renewalGracePeriodDays: this.fixedDepositProductsTemplate.renewalGracePeriodDays ?? '',
minDepositAmount: this.fixedDepositProductsTemplate.minDepositAmount, minDepositAmount: this.fixedDepositProductsTemplate.minDepositAmount,
maxDepositAmount: this.fixedDepositProductsTemplate.maxDepositAmount, maxDepositAmount: this.fixedDepositProductsTemplate.maxDepositAmount,
nominalAnnualInterestRate: this.fixedDepositProductsTemplate.nominalAnnualInterestRate ?? 0, nominalAnnualInterestRate: this.fixedDepositProductsTemplate.nominalAnnualInterestRate ?? 0,
@@ -171,6 +180,13 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
maxDepositTermTypeId: [''], maxDepositTermTypeId: [''],
inMultiplesOfDepositTerm: [''], inMultiplesOfDepositTerm: [''],
inMultiplesOfDepositTermTypeId: [''], inMultiplesOfDepositTermTypeId: [''],
// Days after maturity a depositor can still walk in and have their FD renewed backdated to the
// original maturity date, at the currently active rate -- blank/0 keeps today's instant,
// same-night auto-renewal (see FD Dashboard for the manual renewal this enables).
renewalGracePeriodDays: [
'',
Validators.min(0)
],
minDepositAmount: [ minDepositAmount: [
'', '',
Validators.min(0) Validators.min(0)
@@ -607,6 +607,7 @@
<mifosx-view-savings-accounting-details <mifosx-view-savings-accounting-details
[accountingRule]="fixedDepositProductData.accountingRule" [accountingRule]="fixedDepositProductData.accountingRule"
[accountingMappings]="fixedDepositProductData.accountingMappings" [accountingMappings]="fixedDepositProductData.accountingMappings"
productLabel="Fixed deposit"
></mifosx-view-savings-accounting-details> ></mifosx-view-savings-accounting-details>
} }
</section> </section>
@@ -0,0 +1,426 @@
/**
* 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 { inject } from '@angular/core';
import { Observable, catchError, of } from 'rxjs';
import * as ExcelJS from 'exceljs';
import { RbiCircularsService } from '../rbi-circulars.service';
import {
addBanner,
boxAllCells,
downloadWorkbook,
newWorkbook,
solidFill,
XL
} from '../oss-workbook';
/** One GL account behind a return line. */
export interface OssAccount {
glAccountId: number;
glCode: string;
name: string;
/** Null when the account is pinned but not countable — see `excluded`. */
balance: number | null;
/** Set when the account is still mapped but left out: disabled, or a header. */
excluded?: 'disabled' | 'header';
}
/** One line of a return, as the backend computes it. */
export interface OssLine {
label: string;
/** Null on derived rows and headcounts — they are not mapped to accounts. */
roleKey: string | null;
configured: boolean;
/** Null when the role has no accounts mapped. Never 0 in that case. */
amount: number | null;
accounts: OssAccount[];
/** Derived row, summed from the roles it names. */
subtotal?: boolean;
/** Headcount rather than a rupee amount. */
count?: boolean;
/** Roles a subtotal is summed from. */
components?: string[];
}
/** Which sheet of the return is on screen. */
export type OssTab = 'general' | 'figures' | 'signatory';
/** A `label -> value` row on the General Information sheet. */
export interface OssGeneralInfoRow {
label: string;
value: string;
/** False when the particular is not maintained in the institution profile. */
configured: boolean;
}
export interface OssSignatory {
heading: string;
name: string;
designation: string;
email: string;
telephoneOffice: string;
place: string;
date: string;
}
/** The whole return, as the backend computes it. */
export interface OssReturn {
returnCode: string;
returnName: string;
reportingFrequency: string;
generalInfo: OssGeneralInfoRow[];
quarter: { label: string; startDate: string; endDate: string };
officeId: number | null;
officeName: string;
signatories: OssSignatory[];
basis: { asOnDate?: string; fromDate?: string; toDate?: string; note: string };
computable: boolean;
unmetRequirements: string[];
// OSS 1 only.
liabilities?: OssLine[];
assets?: OssLine[];
totalLiabilities?: number;
totalAssets?: number;
balanced?: boolean;
imbalance?: number;
// OSS 2 only.
earnings?: OssLine[];
expenses?: OssLine[];
totalIncome?: number;
totalExpenditure?: number;
operatingProfit?: number;
}
/**
* Shared behaviour for the live OSS returns — OSS 1 (RAL) and OSS 2 (RoE).
*
* Nothing is computed in the browser. Every figure, and the bank's own particulars, come from one
* endpoint that reads them from the ledger and the institution profile; re-deriving anything here
* would produce a second set of numbers that could disagree with the filed return.
*/
export abstract class OssReturnBase {
protected rbiService = inject(RbiCircularsService);
/** Which return to fetch. */
protected abstract fetch(quarterEndDate: string): Observable<any>;
/** Filename stem for the Excel export. */
protected abstract readonly exportStem: string;
/** Where this return's own GL roles are configured. */
abstract readonly configRoute: string;
abstract readonly configLinkLabel: string;
/** 'OSS 1' / 'OSS 2', for the heading. */
abstract readonly returnShortName: string;
/**
* The one fact a filer needs before reading any figure — whether the return can be filed as it
* stands. Rendered as HTML so a figure inside it can be emphasised.
*/
abstract get headline(): string;
/** Whether the headline states a problem, which decides how the banner reads. */
abstract get headlineIsBad(): boolean;
/** Sheets of the return, in the order RBI prints them. */
readonly tabs: { id: OssTab; label: string; icon: string }[] = [
{ id: 'general', label: 'General Information', icon: 'info-circle' },
{ id: 'figures', label: 'Figures', icon: 'university' },
{ id: 'signatory', label: 'Signatory', icon: 'check-circle' }
];
activeTab: OssTab = 'general';
showTab(tab: OssTab): void {
this.activeTab = tab;
}
/** Bank particulars for the header, read back from the General Information sheet. */
private info(label: string): string {
return this.data?.generalInfo.find((g) => g.label === label)?.value ?? '';
}
get bankName(): string {
return this.info('Bank Name');
}
get bankCode(): string {
return this.info('Bank Code');
}
/** The signatory block as caption/value pairs, in the order every OSS return prints them. */
signatoryFields(signatory: OssSignatory): { caption: string; value: string }[] {
return [
{ caption: 'Name', value: signatory.name },
{ caption: 'Designation', value: signatory.designation },
{ caption: 'e-mail ID', value: signatory.email },
{ caption: 'Tele. No. (O)', value: signatory.telephoneOffice },
{ caption: 'Place', value: signatory.place },
{ caption: 'Date', value: signatory.date }
];
}
data: OssReturn | null = null;
loading = true;
errorMsg = '';
generating = false;
/** Any date in the quarter being reported; the backend resolves it to that quarter. */
quarterDate = new Date().toISOString().slice(0, 10);
toastMsg = '';
toastVisible = false;
private toastTimer: any;
load(): void {
this.loading = true;
this.errorMsg = '';
this.fetch(this.quarterDate)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute this return from the ledger.';
return of(null);
})
)
.subscribe((res: OssReturn | null) => {
this.loading = false;
this.data = res;
});
}
onDateChange(): void {
this.load();
}
/** Jump to the quarter containing today. */
resetToToday(): void {
this.quarterDate = new Date().toISOString().slice(0, 10);
this.load();
}
// ── presentation helpers ──
amount(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
count(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
return value.toLocaleString('en-IN');
}
protected toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
// ── Excel export ──
/**
* Exports the return as it stands: the same figures the screen shows, the basis, and the
* account-level breakdown, so a reviewer can trace any total back to the accounts behind it.
*/
async exportWorkbook(): Promise<void> {
if (!this.data) {
return;
}
this.generating = true;
try {
const wb = newWorkbook();
this.buildGeneralInfoSheet(wb);
this.buildFiguresSheet(wb);
this.buildSignatorySheet(wb);
await downloadWorkbook(wb, `${this.exportStem}_${this.data.quarter.endDate}.xlsx`);
this.toast('Workbook downloaded.');
} finally {
this.generating = false;
}
}
private buildGeneralInfoSheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('General Information');
sheet.columns = [
{ width: 34 },
{ width: 52 }];
addBanner(sheet, 'General Information', 'B');
const start = sheet.rowCount + 1;
for (const row of this.data!.generalInfo) {
const r = sheet.addRow([
row.label,
row.value
]);
r.getCell(1).font = { bold: true };
r.getCell(1).fill = solidFill(XL.keyCell);
// A particular that is not maintained is flagged rather than left looking filled in.
if (!row.configured) {
r.getCell(2).font = { italic: true, color: { argb: 'FFB06000' } };
}
}
const scope = sheet.addRow([
'Office scope',
this.data!.officeName
]);
scope.getCell(1).font = { bold: true };
scope.getCell(1).fill = solidFill(XL.keyCell);
boxAllCells(sheet, start, sheet.rowCount, 2);
}
/** Each section of the return, with its account-level breakdown under every mapped line. */
protected abstract sections(): { title: string; unit: string; lines: OssLine[] }[];
/** Derived totals printed under the figures, in the order the return states them. */
protected abstract totals(): { label: string; value: number | null | undefined }[];
private buildFiguresSheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Figures');
sheet.columns = [
{ width: 66 },
{ width: 22 }];
addBanner(sheet, `${this.data!.returnName} — ${this.data!.quarter.label}`, 'B');
const noteRow = sheet.addRow([this.data!.basis.note]);
sheet.mergeCells(noteRow.number, 1, noteRow.number, 2);
noteRow.alignment = { wrapText: true };
noteRow.font = { size: 9, italic: true };
sheet.addRow([]);
for (const section of this.sections()) {
const start = sheet.rowCount + 1;
const head = sheet.addRow([
section.title,
section.unit
]);
head.font = { bold: true };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const line of section.lines) {
const row = sheet.addRow([
line.label,
line.configured ? line.amount : 'Not configured'
]);
row.getCell(1).alignment = { wrapText: true };
row.getCell(2).numFmt = line.count ? '#,##0' : '#,##0.00';
row.getCell(2).alignment = { horizontal: 'right' };
if (line.subtotal) {
row.font = { bold: true };
row.getCell(2).fill = solidFill(XL.derivedBlue);
}
// An excluded account is named with its reason rather than dropped: the workbook is what
// gets filed, so it must not read as complete when the figure is short by it.
for (const account of line.accounts) {
const sub = sheet.addRow([
` ${account.glCode} — ${account.name}${account.excluded ? ` (excluded — ${account.excluded})` : ''}`,
account.excluded ? 'Excluded' : account.balance
]);
sub.font = { size: 9, color: { argb: account.excluded ? 'FFB06000' : 'FF666666' } };
sub.getCell(2).numFmt = '#,##0.00';
sub.getCell(2).alignment = { horizontal: 'right' };
}
}
boxAllCells(sheet, start, sheet.rowCount, 2);
sheet.addRow([]);
}
const totalsStart = sheet.rowCount + 1;
for (const total of this.totals()) {
const row = sheet.addRow([
total.label,
total.value ?? null
]);
row.font = { bold: true };
row.getCell(2).numFmt = '#,##0.00';
row.getCell(2).alignment = { horizontal: 'right' };
row.getCell(2).fill = solidFill(XL.derivedBlue);
}
boxAllCells(sheet, totalsStart, sheet.rowCount, 2);
// Caveats travel with the workbook, not only the screen: a return with unmapped roles is
// arithmetically right for what is mapped and still must not be filed as complete.
if (this.data!.unmetRequirements.length) {
sheet.addRow([]);
for (const reason of this.data!.unmetRequirements) {
const row = sheet.addRow([`Check before filing: ${reason}`]);
sheet.mergeCells(row.number, 1, row.number, 2);
row.font = { bold: true, size: 9 };
row.alignment = { wrapText: true };
}
}
sheet.views = [{ state: 'frozen', ySplit: 3 }];
}
private buildSignatorySheet(wb: ExcelJS.Workbook): void {
const sheet = wb.addWorksheet('Signatory');
sheet.columns = [
{ width: 26 },
{ width: 52 }];
addBanner(sheet, 'Signatory Information', 'B');
for (const signatory of this.data!.signatories) {
const start = sheet.rowCount + 1;
const heading = sheet.addRow([signatory.heading]);
sheet.mergeCells(heading.number, 1, heading.number, 2);
heading.font = { bold: true };
heading.getCell(1).fill = solidFill(XL.headerGrey);
for (const [
caption,
value
] of [
[
'Name',
signatory.name
],
[
'Designation',
signatory.designation
],
[
'e-mail ID',
signatory.email
],
[
'Tele. No. (O)',
signatory.telephoneOffice
],
[
'Place',
signatory.place
],
[
'Date',
signatory.date
]
]) {
const row = sheet.addRow([
caption,
value || 'Not set in Institution Profile'
]);
row.getCell(1).font = { bold: true };
row.getCell(1).fill = solidFill(XL.keyCell);
if (!value) {
row.getCell(2).font = { italic: true, color: { argb: 'FFB06000' } };
}
}
boxAllCells(sheet, start, sheet.rowCount, 2);
sheet.addRow([]);
}
}
}
@@ -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/.
-->
<div class="oss-root">
@if (loading) {
<div class="oss-section"><div class="oss-sheet-banner">Computing from the ledger…</div></div>
} @else if (errorMsg) {
<div class="oss-balance oss-balance--bad">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ errorMsg }}</span>
</div>
} @else if (data) {
<!-- HEADER -->
<div class="oss-head">
<div class="oss-head-left">
<fa-icon icon="university" class="oss-head-ic"></fa-icon>
<div>
<h2 class="oss-head-t">{{ returnShortName }} — {{ data.returnName }}</h2>
<div class="oss-head-s">
{{ bankName }} &middot; Bank Code <strong>{{ bankCode }}</strong> &middot; Return Code
<strong>{{ data.returnCode }}</strong> &middot; Quarter ending <strong>{{ data.quarter.endDate }}</strong>
</div>
</div>
</div>
<div class="oss-head-right">
<span class="oss-audit-pill">{{ data.quarter.label }}</span>
<input
class="oss-gl-inp"
type="date"
[(ngModel)]="quarterDate"
(change)="onDateChange()"
title="Any date in the quarter to report"
/>
<button class="oss-btn" (click)="resetToToday()">Today</button>
<button class="oss-btn oss-btn-xls" [disabled]="generating" (click)="exportWorkbook()">
<fa-icon icon="file-csv"></fa-icon>
{{ generating ? 'Generating…' : 'Generate Excel' }}
</button>
</div>
</div>
<!--
Headline state, in the same place on every return: the one fact a filer needs before reading
any figure. For OSS 1 that is whether the two sides balance; for OSS 2, the period's result.
-->
<div class="oss-balance" [class.oss-balance--bad]="headlineIsBad">
<fa-icon [icon]="headlineIsBad ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
<span [innerHTML]="headline"></span>
</div>
<!-- TABS -->
<div class="oss-tabs">
@for (t of tabs; track t.id) {
<button class="oss-tab" [class.active]="activeTab === t.id" (click)="showTab(t.id)">
<fa-icon [icon]="t.icon"></fa-icon> {{ t.label }}
</button>
}
</div>
<!--
Caveats ride above the sheets rather than inside one, because a line left blank on any sheet
is a fact about the whole return. A head with nothing mapped is not always a fault: a bank
that holds no investments has no investment account to map, so the list says so rather than
reading as a list of mistakes.
-->
@if (data.unmetRequirements.length) {
<div class="oss-caveat">
<div class="oss-caveat-h">
<fa-icon icon="exclamation-triangle"></fa-icon>
<strong>
{{ data.unmetRequirements.length }}
{{ data.unmetRequirements.length === 1 ? 'line reports' : 'lines report' }} nothing — check before filing
</strong>
</div>
<p class="oss-caveat-lead">
These lines have no GL accounts behind them, so they are left blank rather than reported as nil. If the bank
genuinely has nothing under a head, leaving it unmapped is correct.
</p>
<ul class="oss-caveat-list">
@for (reason of data.unmetRequirements; track reason) {
<li>{{ reason }}</li>
}
</ul>
<a class="oss-caveat-link" [routerLink]="[configRoute]">{{ configLinkLabel }} &rarr;</a>
</div>
}
<!-- GENERAL INFORMATION -->
@if (activeTab === 'general') {
<div class="oss-section">
<div class="oss-sheet-banner">General Information</div>
<div class="oss-tbl-wrap">
<table class="oss-tbl oss-tbl--kv">
<tbody>
@for (item of data.generalInfo; track item.label) {
<tr>
<th>{{ item.label }}</th>
<td [class.filled]="item.configured" [class.oss-unset]="!item.configured">{{ item.value }}</td>
</tr>
}
<tr>
<th>Office scope</th>
<td class="filled">{{ data.officeName }}</td>
</tr>
</tbody>
</table>
</div>
<p class="oss-note">{{ data.basis.note }}</p>
</div>
}
<!-- FIGURES -->
@if (activeTab === 'figures') {
<div class="oss-section">
@for (section of sections(); track section.title) {
<div class="oss-sheet-banner">{{ section.title }}</div>
<div class="oss-tbl-wrap">
<table class="oss-tbl">
<thead>
<tr>
<th>{{ section.title }}</th>
<th class="ta-r">{{ section.unit }}</th>
</tr>
</thead>
<tbody>
@for (line of section.lines; track line.label) {
<tr [class.total-row]="line.subtotal">
<td [class]="line.subtotal ? 'lvl-0' : 'lvl-1'">{{ line.label }}</td>
<td class="ta-r">
@if (!line.configured) {
<span class="oss-unset" title="No GL accounts mapped to this line">Not configured</span>
} @else if (line.count) {
{{ count(line.amount) }}
} @else {
{{ amount(line.amount) }}
}
</td>
</tr>
<!-- Account-level breakdown, so every total traces back to the accounts behind it. -->
@for (account of line.accounts; track account.glAccountId) {
<tr class="oss-subline" [class.oss-subline--excluded]="account.excluded">
<td class="lvl-2">
<span class="oss-gl-code">{{ account.glCode }}</span>
{{ account.name }}
@if (account.excluded) {
<span class="oss-unset">excluded — {{ account.excluded }}</span>
}
</td>
<td class="ta-r">{{ amount(account.balance) }}</td>
</tr>
}
}
@for (total of totals(); track total.label) {
<tr class="total-row">
<td class="lvl-0">{{ total.label }}</td>
<td class="ta-r">{{ amount(total.value) }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
<!-- SIGNATORY -->
@if (activeTab === 'signatory') {
<div class="oss-section">
<div class="oss-sheet-banner">Signatory Information</div>
@for (signatory of data.signatories; track signatory.heading) {
<div class="oss-tbl-wrap">
<div class="oss-tbl-h">{{ signatory.heading }}</div>
<table class="oss-tbl oss-tbl--kv">
<tbody>
@for (field of signatoryFields(signatory); track field.caption) {
<tr>
<th>{{ field.caption }}</th>
<td [class.filled]="field.value" [class.oss-unset]="!field.value">
{{ field.value || 'Not set in Institution Profile' }}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
}
@if (toastVisible) {
<div class="oss-toast show">{{ toastMsg }}</div>
}
</div>
@@ -0,0 +1,106 @@
/**
* 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 } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Observable } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OssLine, OssReturnBase } from './oss-return.base';
/**
* OSS 1 — Return on Assets and Liabilities (RAL), return code R070.
*
* The bank's balance sheet restated on RBI's line structure, every figure read from the ledger as
* at the quarter end. Liabilities must equal assets; where they do not, the return says so rather
* than hiding the difference, because an imbalance is a real condition of the books that the filer
* has to see before filing.
*/
@Component({
selector: 'mifosx-oss1-live',
templateUrl: './oss-return.component.html',
styleUrls: ['../oss-return.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class Oss1LiveComponent extends OssReturnBase implements OnInit {
protected readonly exportStem = 'RBI_OSS1_RAL';
readonly configRoute = '/organization/oss1-gl-config';
readonly configLinkLabel = 'Open OSS 1 GL Configuration';
readonly returnShortName = 'OSS 1';
/**
* Whether the two sides balance — the first thing to check on a balance sheet, and the reason
* the difference is stated rather than absorbed.
*/
get headlineIsBad(): boolean {
return this.data?.balanced === false;
}
get headline(): string {
if (!this.data) {
return '';
}
const liabilities = this.amount(this.data.totalLiabilities);
const assets = this.amount(this.data.totalAssets);
if (!this.headlineIsBad) {
return (
`Total Liabilities <strong>&#8377;${liabilities}</strong> &middot; ` +
`Total Assets <strong>&#8377;${assets}</strong> &middot; the two sides agree`
);
}
return (
`Total Liabilities <strong>&#8377;${liabilities}</strong> &middot; ` +
`Total Assets <strong>&#8377;${assets}</strong> &middot; ` +
`OUT OF BALANCE by <strong>&#8377;${this.amount(Math.abs(this.data.imbalance ?? 0))}</strong> — ` +
'resolve before filing'
);
}
protected fetch(quarterEndDate: string): Observable<any> {
return this.rbiService.getOss1(quarterEndDate);
}
ngOnInit(): void {
this.load();
}
protected sections(): { title: string; unit: string; lines: OssLine[] }[] {
if (!this.data) {
return [];
}
return [
{ title: 'Liabilities', unit: 'Rs in Thousands', lines: this.data.liabilities ?? [] },
{ title: 'Assets', unit: 'Rs in Thousands', lines: this.data.assets ?? [] }
];
}
protected totals(): { label: string; value: number | null | undefined }[] {
if (!this.data) {
return [];
}
const totals: { label: string; value: number | null | undefined }[] = [
{ label: 'Total Liabilities', value: this.data.totalLiabilities },
{ label: 'Total Assets', value: this.data.totalAssets }
];
// Stated only when the two sides disagree: printing a nil difference on every balanced return
// would train a reviewer to skip the line that matters when it is not nil.
if (this.data.balanced === false) {
totals.push({ label: 'Difference (Assets − Liabilities)', value: this.data.imbalance });
}
return totals;
}
/** True when the two sides of the balance sheet disagree. */
get isImbalanced(): boolean {
return this.data?.balanced === false;
}
}
@@ -0,0 +1,88 @@
/**
* 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 } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Observable } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OssLine, OssReturnBase } from './oss-return.base';
/**
* OSS 2 — Return on Earnings (RoE), return code R071.
*
* The bank's profit and loss account on RBI's line structure. Unlike OSS 1 these lines report the
* movement over the quarter rather than a closing balance: the same accounts read as balances
* would carry forward every earlier quarter's income as well.
*/
@Component({
selector: 'mifosx-oss2-live',
templateUrl: './oss-return.component.html',
styleUrls: ['../oss-return.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class Oss2LiveComponent extends OssReturnBase implements OnInit {
protected readonly exportStem = 'RBI_OSS2_RoE';
readonly configRoute = '/organization/oss2-gl-config';
readonly configLinkLabel = 'Open OSS 2 GL Configuration';
readonly returnShortName = 'OSS 2';
/** A loss is worth flagging; it is a result to explain, not merely a number to read. */
get headlineIsBad(): boolean {
return (this.data?.operatingProfit ?? 0) < 0;
}
get headline(): string {
if (!this.data) {
return '';
}
const result = this.data.operatingProfit ?? 0;
return (
`Total Income <strong>&#8377;${this.amount(this.data.totalIncome)}</strong> &middot; ` +
`Total Expenditure <strong>&#8377;${this.amount(this.data.totalExpenditure)}</strong> &middot; ` +
`${result >= 0 ? 'Operating Profit' : 'Operating Loss'} ` +
`<strong>&#8377;${this.amount(Math.abs(result))}</strong> for the quarter`
);
}
protected fetch(quarterEndDate: string): Observable<any> {
return this.rbiService.getOss2(quarterEndDate);
}
ngOnInit(): void {
this.load();
}
protected sections(): { title: string; unit: string; lines: OssLine[] }[] {
if (!this.data) {
return [];
}
return [
{ title: 'Earnings', unit: 'Rs in Thousands', lines: this.data.earnings ?? [] },
{ title: 'Expenditure', unit: 'Rs in Thousands', lines: this.data.expenses ?? [] }
];
}
protected totals(): { label: string; value: number | null | undefined }[] {
if (!this.data) {
return [];
}
return [
{ label: 'Total Income', value: this.data.totalIncome },
{ label: 'Total Expenditure', value: this.data.totalExpenditure },
{
label: (this.data.operatingProfit ?? 0) >= 0 ? 'Operating Profit' : 'Operating Loss',
value: this.data.operatingProfit
}
];
}
}
+89
View File
@@ -567,3 +567,92 @@ td.gl-diff {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* ── Live OSS returns (OSS 1, OSS 2) ────────────────────────────────────────
These read their figures from the ledger rather than from a filed workbook, so they carry two
things a static return does not: a note on which accounts a line came from, and a statement of
what is not mapped. */
/* Caveats about the return as a whole: lines with nothing behind them. Sits above the sheets
because a blank line is a fact about the return, not about the sheet it happens to be on. */
.oss-caveat {
background: var(--o-amber-l);
border: 1px solid #e3d27a;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 14px;
color: var(--o-amber-d);
font-size: 12.5px;
}
.oss-caveat-h {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.oss-caveat-lead {
margin: 6px 0 0;
line-height: 1.6;
opacity: 0.9;
}
.oss-caveat-list {
margin: 8px 0 8px 18px;
padding: 0;
li {
margin-bottom: 4px;
}
}
.oss-caveat-link {
display: inline-block;
margin-top: 4px;
color: var(--o-blue);
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
/* A figure the return could not produce, or an account left out of one. Marked rather than merely
blank, so it reads as deliberately absent instead of as a nil the bank asserted. */
.oss-unset {
color: var(--o-amber-d);
font-style: italic;
font-weight: 500;
}
/* The GL accounts behind a line, indented under it. */
.oss-subline {
td {
font-size: 11.5px;
color: var(--o-g500);
}
&--excluded td {
color: var(--o-amber-d);
}
}
.oss-gl-code {
font-family: 'Roboto Mono', monospace;
font-size: 10.5px;
background: var(--o-g50);
border: 1px solid var(--o-g200);
border-radius: 4px;
padding: 1px 5px;
margin-right: 6px;
}
/* How the figures on a sheet were arrived at — the dates they were read on. */
.oss-note {
margin: 10px 2px 0;
font-size: 11.5px;
color: var(--o-g500);
line-height: 1.6;
}

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