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
anshul 08a427467a A 2026-09-23 15:51:10 +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
171 changed files with 9879 additions and 1180 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
"ng": "ng",
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.localhost.conf.js",
"start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" ng serve --proxy-config proxy.localhost.conf.js",
"start:share": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
"start:demo": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.conf.js",
"serve:dev": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --source-map --proxy-config proxy.localhost.conf.js",
@@ -539,7 +539,7 @@
</div>
<div class="gl-table-shell">
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
<mat-table [dataSource]="dataSource" matSort matSortActive="id" matSortDirection="desc" class="gl-mat-table">
<!-- #ID -->
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef mat-sort-header class="gl-th">#</mat-header-cell>
@@ -6041,7 +6041,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.editRequest = req;
}
const acTyp = 'SD';
// Same bug, same fix as the token-driven withdrawal-slip lookup above (see its comment): the
// request carries its own real account_type (SAVINGS, CURRENT, FIXED_DEPOSIT or
// RECURRING_DEPOSIT), and hardcoding 'SD' here searched every non-savings account on the
// wrong endpoint, so reviewing a Current-account withdrawal always failed with "Account not
// found".
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
const row = this.rows[0];
row.acNo = req.account_no ?? '';
row.acTyp = acTyp;
@@ -643,8 +643,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.createClientForm.get('degreeQualificationId')?.setValue('');
}
});
// Fetch all constitution options from code ID 17
this.clientService.getCodeValuesById(17).subscribe((options) => {
// Constitution options by code NAME, never by id: code ids differ per tenant (on abc_bank id 17 is the empty
// ClientClassification code and Constitution is id 24), so a hardcoded id left the dropdown empty.
this.clientService.getCodeValuesByName('Constitution').subscribe((options) => {
if (options?.length) {
this.constitutionOptions = options;
// Sort master by value length descending so longer/more-specific keys match first
@@ -1321,6 +1321,11 @@
kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched
) {
<mat-error>Only letters, numbers, hyphens and slashes allowed</mat-error>
} @else if (
kycForm.get('kycDocumentNumber')?.hasError('aadhaarLast4Mismatch') &&
kycForm.get('kycDocumentNumber')?.touched
) {
<mat-error>Last 4 digits do not match the Aadhaar number entered above</mat-error>
}
</mat-form-field>
@@ -8,7 +8,14 @@
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { forkJoin, catchError, of } from 'rxjs';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import {
UntypedFormBuilder,
UntypedFormGroup,
Validators,
FormsModule,
AbstractControl,
ValidationErrors
} from '@angular/forms';
import {
MatDialog,
MAT_DIALOG_DATA,
@@ -567,6 +574,28 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Existing signature document ID (for re-upload/delete) */
existingSignatureDocId: string = '';
/**
* Cross-field check for the OVD "Document Number" field (kycDocumentNumber): when Aadhaar Card
* is the selected KYC document, its value must identify the same Aadhaar as the aadhaarNumber
* field captured earlier in this step - the last 4 digits are what's compared, since
* aadhaarNumber may hold either the raw 12-digit number or a masked value (masking only ever
* obscures the first 8 digits - see aadhaarMasked in the kycData getter below - so the last 4
* characters are always the real digits either way). Not applicable to any other document type,
* where kycDocumentNumber is unrelated to Aadhaar entirely.
*/
private aadhaarLast4MatchValidator = (control: AbstractControl): ValidationErrors | null => {
if (this.selectedKycDoc?.code !== 'AADHAAR') {
return null;
}
const docNumber = (control.value || '').toString().trim();
const aadhaarValue = (control.parent?.get('aadhaarNumber')?.value || '').toString().trim();
if (docNumber.length < 4 || aadhaarValue.length < 4) {
// Required/pattern validators already flag an empty or too-short value on their own.
return null;
}
return docNumber.slice(-4) === aadhaarValue.slice(-4) ? null : { aadhaarLast4Mismatch: true };
};
/** KYC Form */
kycForm: UntypedFormGroup = this.formBuilder.group({
hasPan: [true],
@@ -616,7 +645,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
[
Validators.required,
Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/),
Validators.maxLength(30)
Validators.maxLength(30),
this.aadhaarLast4MatchValidator
]
],
kycDocIssueDate: [''],
@@ -739,6 +769,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycForm.get('ecgcSalFlag')?.disable();
this.kycForm.get('dormantFlag')?.disable();
}
// aadhaarLast4MatchValidator (on kycDocumentNumber) reads this control's value directly rather
// than through its own valueChanges, so a re-check has to be triggered explicitly whenever the
// Aadhaar field itself changes - otherwise fixing aadhaarNumber after kycDocumentNumber was
// already entered would leave a stale mismatch error in place.
this.kycForm.get('aadhaarNumber')?.valueChanges.subscribe(() => {
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
});
}
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
@@ -1009,6 +1047,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Reset uploads + merged preview when document type changes
this.kycDocFiles = { front: null, back: null };
this.kycDocMergedPreview = null;
// aadhaarLast4MatchValidator only applies while Aadhaar Card is selected - re-check so switching
// to/away from it immediately picks up (or clears) the mismatch error.
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
}
isKycDocSelected(proof: ProofType): boolean {
@@ -1189,6 +1230,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Sync chip-picker selection with the prefilled document name
if (data.kycDocumentName) {
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
// patchValue above ran kycDocumentNumber's validators before selectedKycDoc was known,
// so aadhaarLast4MatchValidator needs a fresh check now that it is.
this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
}
// Set verification statuses from existing data
if (data.panVerified) {
@@ -61,6 +61,7 @@ import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
import { tdsConfigResolver } from '../letter-mgmt/certificates/form-15gh/tds-config.service';
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
import { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component';
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
@@ -198,6 +199,7 @@ const routes: Routes = [
{
path: 'form121',
component: Form121TabComponent,
resolve: { tdsConfig: tdsConfigResolver },
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
},
{
@@ -202,9 +202,14 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.ADDRESS_PROOF.source }}</span>
@if (existing.ADDRESS_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) {
@if (
(existing.ADDRESS_PROOF.previewUrl || existing.ADDRESS_PROOF.previewUrlBack) && isImage('ADDRESS_PROOF')
) {
<div class="ckyc-existing__thumbs">
@if (existing.ADDRESS_PROOF.previewUrl) {
<img
@@ -222,6 +227,17 @@
}
</div>
}
@if (existing.ADDRESS_PROOF.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('ADDRESS_PROOF')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-alt"></fa-icon>
<span>{{ existing.ADDRESS_PROOF.name }}</span>
@@ -299,9 +315,12 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.PHOTO_PROOF.source }}</span>
@if (existing.PHOTO_PROOF.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) {
@if ((existing.PHOTO_PROOF.previewUrl || existing.PHOTO_PROOF.previewUrlBack) && isImage('PHOTO_PROOF')) {
<div class="ckyc-existing__thumbs">
@if (existing.PHOTO_PROOF.previewUrl) {
<img
@@ -319,6 +338,17 @@
}
</div>
}
@if (existing.PHOTO_PROOF.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('PHOTO_PROOF')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="id-card"></fa-icon>
<span>{{ existing.PHOTO_PROOF.name }}</span>
@@ -393,11 +423,25 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_IMAGE.source }}</span>
@if (existing.CLIENT_IMAGE.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.CLIENT_IMAGE.previewUrl) {
@if (existing.CLIENT_IMAGE.previewUrl && isImage('CLIENT_IMAGE')) {
<img [src]="existing.CLIENT_IMAGE.previewUrl" alt="Client photo" class="ckyc-existing__thumb" />
}
@if (existing.CLIENT_IMAGE.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('CLIENT_IMAGE')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<button
mat-stroked-button
type="button"
@@ -448,15 +492,29 @@
<div>
<strong>Already available</strong>
<span class="ckyc-existing__source">from {{ existing.CLIENT_SIGNATURE.source }}</span>
@if (existing.CLIENT_SIGNATURE.previewUnavailable) {
<span class="ckyc-existing__warn">stored file unavailable</span>
}
</div>
</div>
@if (existing.CLIENT_SIGNATURE.previewUrl) {
@if (existing.CLIENT_SIGNATURE.previewUrl && isImage('CLIENT_SIGNATURE')) {
<img
[src]="existing.CLIENT_SIGNATURE.previewUrl"
alt="Client signature"
class="ckyc-existing__thumb ckyc-existing__thumb--wide"
/>
}
@if (existing.CLIENT_SIGNATURE.previewUrl) {
<button
mat-stroked-button
type="button"
class="ckyc-existing__view"
(click)="openDocument('CLIENT_SIGNATURE')"
>
<fa-icon icon="eye"></fa-icon>
&nbsp;View document
</button>
}
<div class="ckyc-existing__meta">
<fa-icon icon="file-signature"></fa-icon>
<span>{{ existing.CLIENT_SIGNATURE.name }}</span>
@@ -623,6 +623,35 @@
}
}
/* Opens the uploaded artefact full size in a new tab. Sits directly under the thumbnail, and is
the only way to inspect a PDF - which cannot render as an inline <img>. */
.ckyc-existing__view {
align-self: flex-start;
color: #1d6fd0;
border-color: #bcd8f5;
background: #fff;
font-size: 13px;
font-weight: 600;
&:hover {
background: #eef6ff;
}
}
/* Shown when a document row exists but its file could not be fetched from the content store,
so "Already available" is not silently misleading. */
.ckyc-existing__warn {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 10px;
background: #fdecec;
border: 1px solid #f3c2c2;
color: #a12b2b;
font-size: 11px;
font-weight: 600;
}
.ckyc-existing {
display: flex;
flex-direction: column;
@@ -36,11 +36,41 @@ interface ExistingArtefact {
* already writes/reads for address proof; now shared by the photo/identity proof too. */
description?: string;
fileCount?: number;
/** Set when the stored file could not be fetched - the row exists but its content is gone. */
previewUnavailable?: boolean;
/** Stored MIME type, so the card knows whether it can render an inline thumbnail. */
mimeType?: string;
}
type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF';
type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE';
/**
* Client-side upload guards, kept deliberately in step with the backend so a file that passes here
* is not rejected server-side:
* - 5 MB matches `spring.servlet.multipart.max-file-size`
* - the extension list is the intersection of the backend `fineract.content.regex-whitelist`
* with what these cards can actually render.
* The dialog's `accept=` is only a file-picker hint and is trivially bypassed, so the real check
* runs here, on the file the dialog hands back, before anything is sent.
*/
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
const IMAGE_EXTENSIONS = [
'png',
'jpeg',
'jpg'
];
const DOCUMENT_EXTENSIONS = [
...IMAGE_EXTENSIONS,
'pdf'
];
/** Cards that render an inline <img>, so a PDF would not display in them. */
const IMAGE_ONLY_KINDS: CkycDocKind[] = [
'CLIENT_IMAGE',
'CLIENT_SIGNATURE'
];
@Component({
selector: 'mifosx-ckyc-tab',
templateUrl: './ckyc-tab.component.html',
@@ -150,6 +180,11 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.route.parent.params.subscribe((params: any) => {
this.clientId = params['clientId'];
// Angular reuses this component when only the clientId in the route changes, and
// loadExistingArtefacts() only ever *assigns* what it finds - it has no "not found" branch.
// Without clearing first, a client with no photo (or no signature, or no proof) keeps showing
// the previous client's card.
this.resetArtefactState();
this.loadClientInfo();
this.loadAccounts();
this.loadCkycData();
@@ -157,6 +192,46 @@ export class CkycTabComponent implements OnInit, OnDestroy {
});
}
/**
* Returns every per-client card to its empty state, so nothing from the client just viewed can
* survive into the next one. Covers all four cards plus the details staged for an upload - a file
* or document number left over from another client must never be submitted against this one.
*/
private resetArtefactState(): void {
this.revokePreviewUrls();
this.existing = {
ADDRESS_PROOF: { present: false },
PHOTO_PROOF: { present: false },
CLIENT_IMAGE: { present: false },
CLIENT_SIGNATURE: { present: false }
};
this.selectedProofType = { ADDRESS_PROOF: null, PHOTO_PROOF: null };
this.docFiles = {
ADDRESS_PROOF: { front: null, back: null },
PHOTO_PROOF: { front: null, back: null }
};
this.documentDetails = {
ADDRESS_PROOF: { documentKey: '', issueDate: null, expiryDate: null },
PHOTO_PROOF: { documentKey: '', issueDate: null, expiryDate: null }
};
this.clientImageFile = null;
this.clientSignatureFile = null;
this.uploadErrors = {
ADDRESS_PROOF: null,
PHOTO_PROOF: null,
CLIENT_IMAGE: null,
CLIENT_SIGNATURE: null
};
}
/** Frees the blob URLs held by the current cards before their references are dropped. */
private revokePreviewUrls(): void {
Object.values(this.existing).forEach((e) => {
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
});
}
private loadClientInfo(): void {
this.route.parent.data.subscribe((data: any) => {
const cv = data?.clientViewData;
@@ -280,19 +355,29 @@ export class CkycTabComponent implements OnInit, OnDestroy {
present: true,
source: 'Client registration',
name: signatureDoc.fileName || 'Client signature',
documentId: signatureDoc.id
documentId: signatureDoc.id,
mimeType: signatureDoc.type
};
// Fetch blob for preview
// Same endpoint getClientSignatureImage() uses, but opted out of the global error alert:
// a m_document row whose file is no longer in the content store returns 500, and the card
// below reports that itself. Without `silent` the interceptor pops an "Internal Server
// Error" modal on every page load, for a failure the user cannot act on.
this.clientsService
.getClientSignatureImage(this.clientId, signatureDoc.id)
.downloadClientDocument(this.clientId, String(signatureDoc.id), true)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
if (blob) {
if (blob && blob.size > 0) {
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
} else {
// A 200 with an empty body still means there is nothing to show. Without this the
// card would claim "Already available" with no thumbnail and no explanation.
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
},
error: () => undefined
error: () => {
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
});
}
@@ -364,6 +449,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
const list = Array.isArray(docs) ? docs : [];
if (!list.length) return;
this.existing[kind].fileCount = list.length;
this.existing[kind].previewUnavailable = false;
const byNewest = (a: any, b: any) => b.id - a.id;
const label = (d: any) => (d?.name || d?.fileName || '').toString();
@@ -372,6 +458,9 @@ export class CkycTabComponent implements OnInit, OnDestroy {
[...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] ||
[...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0];
// A proof can be scanned to PDF, which no <img> can render - remember what was stored so the
// card offers "View document" instead of a broken thumbnail.
if (front) this.existing[kind].mimeType = front.type;
if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front');
if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back');
});
@@ -388,12 +477,19 @@ export class CkycTabComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
if (!blob) return;
if (!blob || blob.size === 0) {
this.existing[kind].previewUnavailable = true;
return;
}
const url = URL.createObjectURL(blob);
if (slot === 'front') this.existing[kind].previewUrl = url;
else this.existing[kind].previewUrlBack = url;
},
error: () => undefined
// The row exists but its file is gone from the content store. Saying so on the card beats
// showing "Already available" next to a blank space.
error: () => {
this.existing[kind].previewUnavailable = true;
}
});
}
@@ -452,6 +548,18 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => {
if (!result) return;
// The dialog's `accept=` filter is only a hint to the file picker and is trivially bypassed,
// so the size/type guard runs here on what actually came back. Catching it now turns a raw
// server rejection into a message on the card.
const rejection =
this.validationError(result.front, kind) ?? (result.back ? this.validationError(result.back, kind) : null);
if (rejection) {
this.uploadErrors[kind] = rejection;
return;
}
this.uploadErrors[kind] = null;
if (isProofKind) {
const proofKind = kind as ProofKind;
this.selectedProofType[proofKind] = result.proofType || null;
@@ -470,6 +578,66 @@ export class CkycTabComponent implements OnInit, OnDestroy {
});
}
/** Extensions a given card can both store and render. */
private allowedExtensions(kind: CkycDocKind): string[] {
return IMAGE_ONLY_KINDS.includes(kind) ? IMAGE_EXTENSIONS : DOCUMENT_EXTENSIONS;
}
/**
* Rejects a file the backend would refuse (or the card could not render) before it is uploaded,
* so the user gets a clear message instead of a raw server error. Returns null when the file is
* acceptable.
*/
private validationError(file: File | null | undefined, kind: CkycDocKind): string | null {
if (!file) {
return null;
}
const allowed = this.allowedExtensions(kind);
const extension = (file.name.split('.').pop() || '').toLowerCase();
if (!allowed.includes(extension)) {
return `Unsupported file type ".${extension}". Allowed: ${allowed.map((e) => e.toUpperCase()).join(', ')}.`;
}
if (file.size > MAX_UPLOAD_BYTES) {
const mb = (file.size / (1024 * 1024)).toFixed(1);
return `File is ${mb} MB. The maximum allowed size is 5 MB.`;
}
if (file.size === 0) {
return 'The selected file is empty.';
}
return null;
}
/**
* True when the stored file can be shown as an inline thumbnail. A PDF cannot, so its card falls
* back to the "View document" button alone rather than rendering a broken image.
*/
isImage(kind: CkycDocKind): boolean {
const entry = this.existing[kind];
// The profile image arrives as a `data:image/...;base64,` URL, which declares its own type -
// it has neither a stored MIME type nor a file extension for the checks below to read.
if (entry?.previewUrl?.startsWith('data:')) {
return entry.previewUrl.startsWith('data:image/');
}
if (entry?.mimeType) {
return entry.mimeType.startsWith('image/');
}
// Older rows can have no stored MIME type - fall back to the file extension.
const extension = (entry?.name?.split('.').pop() || '').toLowerCase();
return IMAGE_EXTENSIONS.includes(extension);
}
/**
* Opens the uploaded document full size in a new tab. Uses the blob already fetched for the
* thumbnail, so it works for PDFs too and costs no extra request.
*/
openDocument(kind: CkycDocKind): void {
const url = this.existing[kind]?.previewUrl;
if (url) {
window.open(url, '_blank', 'noopener');
}
}
/** Reads back the "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" convention this tab (and
* client-india-address-step, for address proof) writes into the identifier's description. */
private parseDescriptionDates(description: string | undefined): { issueDate: Date | null; expiryDate: Date | null } {
@@ -533,6 +701,10 @@ export class CkycTabComponent implements OnInit, OnDestroy {
request$.pipe(takeUntil(this.destroy$)).subscribe({
next: () => {
this.uploading[documentType] = false;
// The card is gated on `present`; without this a successful upload left it reading
// "Not uploaded yet". uploadProofDocument() sets the same flag for the proof cards.
this.existing[key].present = true;
this.existing[key].source = this.existing[key].source || 'CKYC upload';
this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' });
this.refreshPreview(documentType);
},
@@ -653,13 +825,22 @@ export class CkycTabComponent implements OnInit, OnDestroy {
if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) {
URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!);
}
// The upload that triggered this refresh is what repairs a previously missing file, so the
// warning has to be cleared before the re-read - otherwise a fixed card keeps the old badge.
this.existing[staleKey].previewUrl = undefined;
this.existing[staleKey].previewUrlBack = undefined;
this.existing[staleKey].previewUnavailable = false;
switch (documentType) {
case 'CLIENT_IMAGE':
this.clientsService
.getClientProfileImage(this.clientId)
.pipe(takeUntil(this.destroy$))
.subscribe((photo: any) => {
if (photo) this.existing.CLIENT_IMAGE.previewUrl = photo;
if (photo) {
this.existing.CLIENT_IMAGE.previewUrl = photo;
} else {
this.existing.CLIENT_IMAGE.previewUnavailable = true;
}
});
break;
case 'CLIENT_SIGNATURE':
@@ -672,11 +853,21 @@ export class CkycTabComponent implements OnInit, OnDestroy {
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (!doc) return;
this.existing.CLIENT_SIGNATURE.documentId = doc.id;
this.existing.CLIENT_SIGNATURE.mimeType = doc.type;
this.clientsService
.getClientSignatureImage(this.clientId, doc.id)
.downloadClientDocument(this.clientId, String(doc.id), true)
.pipe(takeUntil(this.destroy$))
.subscribe((blob: any) => {
if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
.subscribe({
next: (blob: any) => {
if (blob && blob.size > 0) {
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
} else {
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
},
error: () => {
this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
});
});
break;
@@ -690,10 +881,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
// Release object URLs
Object.values(this.existing).forEach((e) => {
if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
});
this.revokePreviewUrls();
}
}
@@ -55,7 +55,10 @@
<div class="f121__note">
<mat-icon>info_outline</mat-icon>
<div>
@if (!dateOfBirth) {
@if (configMissing) {
TDS rates for FY {{ fy }} are not configured, so the tax cannot be computed. Set them up under
Organization › TDS Configuration.
} @else if (!dateOfBirth) {
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
Data tab.
} @else {
@@ -153,6 +153,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
schedule: TdsSchedule | null = null;
actualTds = 0;
tdsSaved = 0;
/** The selected year has no entry on Organization › TDS Configuration. */
configMissing = false;
/** Raw account inputs, kept so the year selector can recompute cheaply. */
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
@@ -212,7 +214,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
this.panWellFormed = isWellFormedPan(this.pan);
this.declarationOnRecord = !!kyc?.form15Applicable;
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
this.declarationForm =
this.fy && FY_TAX_CONFIG[normaliseFy(this.fy)] ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
this.declarationFy = kyc?.form15FY ?? '';
this.acceptanceDateKnown = false;
}
@@ -333,6 +336,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
}
const year = normaliseFy(this.fy);
this.configMissing = !FY_TAX_CONFIG[year];
// Per-account interest for the selected year, for the three lists.
for (const a of this.accountInputs) {
@@ -340,7 +344,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
}
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
if (!this.dateOfBirth) {
if (!this.dateOfBirth || this.configMissing) {
return;
}
@@ -411,7 +415,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
/** The form the payee would file for the selected year. */
get formLabel(): string {
if (!this.fy) {
if (!this.fy || this.configMissing) {
return '—';
}
const form = declarationFormFor(this.fy, this.dateOfBirth);
@@ -1247,9 +1247,7 @@
</div>
@if (!docScansLoading && !docScans.front && !docScans.back) {
<div class="kyc-doc-scans__empty">
No scan is on file for this document. Upload one below.
</div>
<div class="kyc-doc-scans__empty">No scan is on file for this document. Upload one below.</div>
}
<div class="kyc-doc-scans__sides">
@@ -182,7 +182,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null };
/** The two sides, in display order, so the template loops rather than repeating itself. */
readonly docScanSlots: ('front' | 'back')[] = ['front', 'back'];
readonly docScanSlots: ('front' | 'back')[] = [
'front',
'back'
];
docScanOf(slot: 'front' | 'back') {
return this.docScans[slot];
@@ -1455,7 +1458,10 @@ I further confirm that:
this.docScansLoading = false;
// This runs again after every replace, so the previews being thrown away are revoked
// rather than left holding their blobs.
for (const old of [this.docScans.front, this.docScans.back]) {
for (const old of [
this.docScans.front,
this.docScans.back
]) {
if (old?.previewUrl) {
URL.revokeObjectURL(old.previewUrl);
}
@@ -1497,7 +1503,10 @@ I further confirm that:
}
this.docScans = sides;
for (const key of ['front', 'back'] as const) {
for (const key of [
'front',
'back'
] as const) {
if (sides[key]) {
this.loadScanPreview(sides[key]!);
}
@@ -1558,7 +1567,9 @@ I further confirm that:
error: (err: any) => {
this.docScanUploading = null;
this.docScanError =
err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`;
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
`Could not replace the ${label.toLowerCase()} side.`;
}
});
}
@@ -1741,7 +1752,10 @@ I further confirm that:
this.destroy$.next();
this.destroy$.complete();
// Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab.
for (const side of [this.docScans.front, this.docScans.back]) {
for (const side of [
this.docScans.front,
this.docScans.back
]) {
if (side?.previewUrl) {
URL.revokeObjectURL(side.previewUrl);
}
+33 -6
View File
@@ -569,8 +569,19 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/documents`);
}
downloadClientDocument(parentEntityId: string, documentId: string) {
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, { responseType: 'blob' });
/**
* Fetches a client document's stored file.
*
* `silent` opts the request out of ErrorHandlerInterceptor's alert. Pass it when the caller
* renders its own failure state - notably thumbnail fetches, where a `m_document` row whose file
* is no longer in the content store returns 500 and would otherwise pop a modal the user can do
* nothing about. The interceptor still rethrows, so the caller's error callback continues to run.
*/
downloadClientDocument(parentEntityId: string, documentId: string, silent = false) {
return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, {
responseType: 'blob',
...(silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {})
});
}
uploadClientDocument(clientId: string, documentData: any) {
@@ -992,12 +1003,28 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data);
}
getClientCkyc(clientId: string): Observable<any> {
return this.http.get(`/clients/${clientId}/ckyc`);
/**
* Uploads a single CKYC artefact against the client, using the platform's generic document
* endpoint (DocumentApiResource, POST /v1/{entityType}/{entityId}/documents). There is no
* CKYC-specific document API on the backend - the earlier `/clients/{id}/ckyc/documents` path
* was never implemented and returned 404.
*
* The multipart body must carry `file`, `name` and `description`; the caller builds those so the
* stored document stays identifiable by CKYC artefact type. See ckyc-tab.component.ts.
*/
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
return this.http.post(`/clients/${clientId}/documents`, formData);
}
uploadCkycDocument(clientId: string, formData: FormData): Observable<any> {
return this.http.post(`/clients/${clientId}/ckyc/documents`, formData);
/**
* Replaces the content of an existing client document in place (DocumentApiResource PUT
* /v1/{entityType}/{entityId}/documents/{documentId}), keeping the same document id.
*
* Used by the CKYC screen's "Upload new version" so a re-upload updates the existing artefact
* rather than adding another row. Takes the same multipart body as the create call.
*/
replaceClientDocument(clientId: string, documentId: string, formData: FormData): Observable<any> {
return this.http.put(`/clients/${clientId}/documents/${documentId}`, formData);
}
/**
@@ -109,13 +109,7 @@
</button>
}
</div>
<input
#sideInput
type="file"
hidden
accept="image/*,application/pdf"
(change)="onFilePicked($event, slot)"
/>
<input #sideInput type="file" hidden accept="image/*,application/pdf" (change)="onFilePicked($event, slot)" />
</div>
}
</div>
+13 -1
View File
@@ -25,6 +25,18 @@ import { environment } from '../../../environments/environment';
export class CacheInterceptor implements HttpInterceptor {
private forceUpdate = false;
/**
* Endpoints that compute a live figure from the current ledger/configuration and must never be
* served from cache. Caching them meant a report kept showing a stale response after its
* underlying GL mapping changed — the request was answered from cachedData and the backend was
* never hit again. Matched as a substring of the request URL.
*/
private static readonly NEVER_CACHE = ['/rbireservereturn', '/rbireserveconfig', '/tdsconfig', '/tds/deposit-interest'];
private isCacheable(url: string): boolean {
return !CacheInterceptor.NEVER_CACHE.some((fragment) => url.includes(fragment));
}
// eslint-disable-next-line @angular-eslint/prefer-inject
constructor(private httpCacheService: HttpCacheService) {}
@@ -42,7 +54,7 @@ export class CacheInterceptor implements HttpInterceptor {
}
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (request.method !== 'GET') {
if (request.method !== 'GET' || !this.isCacheable(request.urlWithParams)) {
return next.handle(request);
}
+22
View File
@@ -2071,6 +2071,28 @@
<fa-icon icon="users" class="gl-icon" style="color: #4f46e5"></fa-icon> Members &amp; Share Capital
</a>
</div>
<!-- Reserve Returns — CRR and SLR, computed live from the ledger. Grouped apart from the
OSS returns because they are filed on their own cycles: Form 1 twice a month, Form 9
at month end. -->
<div class="gl-group">
<div class="gl-group-title">Reserve Returns</div>
<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 -->
<div class="gl-group">
<div class="gl-group-title">CIMS / XBRL Returns</div>
@@ -8,7 +8,10 @@
<div class="cur-view">
<!-- ─── Dormancy risk alert (only when subStatus has left Active) ──── -->
<mifosx-dormancy-alert-panel [accountData]="currentAccountData"></mifosx-dormancy-alert-panel>
<!-- Shown only when the account's product has Dormancy Alert = Yes -->
@if (currentAccountData?.dormancyAlert) {
<mifosx-dormancy-alert-panel [accountData]="currentAccountData"></mifosx-dormancy-alert-panel>
}
<!-- ─── Gradient hero header ─────────────────────────────────────── -->
<header class="cur-hero">
@@ -59,16 +59,7 @@
<span class="nx-mono">[{{ currentAccountData.currency.code }}]</span>
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Nominal Interest Rate' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
@if (currentAccountData.nominalAnnualInterestRate) {
{{ currentAccountData.nominalAnnualInterestRate | formatNumber }} %
} @else {
0 %
}
</span>
</div>
<!-- A current account earns no interest, so no interest rate is shown here; OD interest is under Overdraft. -->
} @else {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Field Officer' | translate }}</span>
@@ -102,17 +93,17 @@
</span>
</div>
}
@if (currentAccountData.summary.totalInterestEarned >= 0) {
@if (currentAccountData.summary.totalWithdrawals) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Total Interest Earned' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
<span class="nx-row__label">{{ 'labels.inputs.Total Withdrawls' | translate }}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--negative">
{{
currentAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2'
currentAccountData.summary.totalWithdrawals | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (!currentAccountData.summary.totalDeposits && !(currentAccountData.summary.totalInterestEarned >= 0)) {
@if (!currentAccountData.summary.totalDeposits && !currentAccountData.summary.totalWithdrawals) {
<div class="nx-empty">
<fa-icon icon="inbox" class="nx-empty__ico"></fa-icon>
<span>No activity yet.</span>
@@ -132,16 +123,7 @@
<h3 class="nx-card__title">{{ 'labels.heading.Account Summary' | translate }}</h3>
</header>
<div class="nx-card__body">
@if (currentAccountData.summary.totalWithdrawals) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Total Withdrawls' | translate }}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--negative">
{{
currentAccountData.summary.totalWithdrawals | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
<!-- A current account earns no interest: no interest earned/posted rows. OD interest has its own card. -->
@if (currentAccountData.summary.totalWithdrawalFees) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withdrawals Fees' | translate }}</span>
@@ -160,89 +142,6 @@
</span>
</div>
}
@if (currentAccountData.summary.totalInterestEarned >= 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Earned' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.totalInterestEarned | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.summary.totalInterestPosted) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Posted' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.totalInterestPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.summary.interestNotPosted >= 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Earned Not Posted' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.summary.totalOverdraftInterestDerived) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest On Overdraft' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.totalOverdraftInterestDerived
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.summary.interestNotPosted < 0) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Overdraft Interest Not Posted' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{
currentAccountData.summary.interestNotPosted | currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.nominalAnnualInterestRate) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Nominal Interest Rate' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ currentAccountData.nominalAnnualInterestRate | formatNumber }} %
</span>
</div>
}
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Compounding Period' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.interestCompoundingPeriodType.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Posting Period' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.interestPostingPeriodType.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Calculated Using' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.interestCalculationType.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Days in Year' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.interestCalculationDaysInYearType.value | translateKey: 'catalogs' }}
</span>
</div>
@if (currentAccountData.withdrawalFee) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withdrawal Fee' | translate }}</span>
@@ -289,37 +188,6 @@
</span>
</div>
}
@if (currentAccountData.allowOverdraft) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Overdraft Limit' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
{{ currentAccountData.overdraftLimit | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (currentAccountData.allowOverdraft) {
<div class="nx-row">
<span class="nx-row__label">
{{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}
</span>
<span class="nx-row__value">
{{
currentAccountData.minOverdraftForInterestCalculation
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
@if (currentAccountData.minBalanceForInterestCalculation) {
<div class="nx-row">
<span class="nx-row__label">
{{ 'labels.inputs.Min Balance Required for Interest Calculation' | translate }}
</span>
<span class="nx-row__value">
{{ currentAccountData.minBalanceForInterestCalculation | formatNumber }}
</span>
</div>
}
@if (currentAccountData.minRequiredBalance) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Minimum Required Balance' | translate }}</span>
@@ -332,14 +200,6 @@
<span class="nx-row__value">{{ currentAccountData.enforceMinRequiredBalance | yesNo }}</span>
</div>
}
@if (currentAccountData.summary.lastInterestCalculationDate) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Interest Recalculation Date' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.summary.lastInterestCalculationDate | dateFormat }}
</span>
</div>
}
@if (currentAccountData.onHoldFunds) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.On Hold Funds' | translate }}</span>
@@ -350,14 +210,72 @@
</span>
</div>
}
@if (currentAccountData.withHoldTax) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Withhold Tax Group' | translate }}</span>
<span class="nx-row__value">{{ currentAccountData.taxGroup.name }}</span>
</div>
}
</div>
</section>
<!-- Overdraft: the only interest on a current account is what the customer pays on an OD. -->
@if (currentAccountData.allowOverdraft) {
<section class="nx-card">
<header class="nx-card__head">
<fa-icon icon="hand-holding-usd" class="nx-card__ico"></fa-icon>
<h3 class="nx-card__title">Overdraft (OD)</h3>
</header>
<div class="nx-card__body">
<div class="nx-row">
<span class="nx-row__label">Maximum OD Limit (per product)</span>
<span class="nx-row__value nx-row__value--amount">
{{ currentAccountData.overdraftLimit | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
@if (currentAccountData.nominalAnnualInterestRateOverdraft) {
<div class="nx-row">
<span class="nx-row__label">OD Interest Rate</span>
<span class="nx-row__value">
{{ currentAccountData.nominalAnnualInterestRateOverdraft | formatNumber }} % p.a.
</span>
</div>
}
@if (currentAccountData.minOverdraftForInterestCalculation) {
<div class="nx-row">
<span class="nx-row__label">Minimum OD for Interest</span>
<span class="nx-row__value">
{{
currentAccountData.minOverdraftForInterestCalculation
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
}
<div class="nx-row">
<span class="nx-row__label">OD Interest Posting</span>
<span class="nx-row__value">
{{ currentAccountData.interestPostingPeriodType?.value | translateKey: 'catalogs' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Days in Year' | translate }}</span>
<span class="nx-row__value">
{{ currentAccountData.interestCalculationDaysInYearType?.value | translateKey: 'catalogs' }}
</span>
</div>
@if (currentAccountData.validForPeriod) {
<div class="nx-row">
<span class="nx-row__label">Sanction Validity</span>
<span class="nx-row__value">{{ currentAccountData.validForPeriod }}</span>
</div>
}
<div class="nx-row">
<span class="nx-row__label">OD Interest Charged</span>
<span class="nx-row__value nx-row__value--amount">
{{
(currentAccountData.summary.totalOverdraftInterestDerived || 0)
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
</div>
</div>
</section>
}
} @else {
<section class="nx-card">
<header class="nx-card__head">
@@ -54,8 +54,8 @@
<mat-icon>account_balance</mat-icon>
</div>
<div class="bank-name-block">
<div class="bank-title">LincPay Co-operative Bank Ltd.</div>
<div class="bank-subtitle">Current Account Statement</div>
<div class="bank-title">{{ bankName }}</div>
<div class="bank-subtitle">{{ bankSubtitle }}</div>
</div>
</div>
<div class="statement-label">
@@ -64,6 +64,25 @@
</div>
</div>
<!-- Branch line — IFSC/MICR are how a UCB customer identifies which branch issued this, and are
needed for NEFT/RTGS/cheque printing, so a real passbook always carries them. -->
@if (branchName || ifscCode || micrCode) {
<div class="branch-line">
@if (branchName) {
<span>{{ branchName }} Branch</span>
}
@if (branchAddress) {
<span>{{ branchAddress }}</span>
}
@if (ifscCode) {
<span>IFSC: {{ ifscCode }}</span>
}
@if (micrCode) {
<span>MICR: {{ micrCode }}</span>
}
</div>
}
<!-- Account Details -->
<div class="account-details-band">
<div class="account-details-grid">
@@ -127,6 +127,18 @@ $header-text: #fff;
}
}
// ─── Branch Identification Line ──────────────────────────────────────────────
.branch-line {
background: #e8edf5;
border-bottom: 1px solid $border-color;
padding: 6px 24px;
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
font-size: 11px;
color: #4a5a7a;
}
// ─── Account Details Band ────────────────────────────────────────────────────
.account-details-band {
background: #f0f3f9;
@@ -11,11 +11,12 @@ import { Component, OnInit, OnDestroy, inject, ElementRef, ViewChild } from '@an
import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder } from '@angular/forms';
import { CurrencyPipe } from '@angular/common';
import { Subject, takeUntil } from 'rxjs';
import { Subject, takeUntil, catchError, forkJoin, of } from 'rxjs';
/** Custom Services */
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { OrganizationService } from '../../../organization/organization.service';
import { Currency } from 'app/shared/models/general.model';
import {
CurrentAccountTransaction,
@@ -44,6 +45,7 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private organizationService = inject(OrganizationService);
private destroy$ = new Subject<void>();
@@ -60,6 +62,17 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
availableBalance: number = 0;
activatedOnDate: any = null;
/** Bank/branch letterhead — real values from Institution Profile + Branch Profile (Organization
* module) rather than a hardcoded name, so a printed passbook actually identifies which bank and
* branch issued it, the way a real UCB's core banking system does. Falls back to a generic label
* if the profile was never filled in, rather than showing a fabricated bank name. */
bankName = 'Bank';
bankSubtitle = 'Current Account Statement';
branchName = '';
branchAddress = '';
ifscCode = '';
micrCode = '';
/** Transactions */
allTransactions: CurrentAccountTransaction[] = [];
filteredTransactions: CurrentAccountTransaction[] = [];
@@ -92,6 +105,33 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
fromDate: [''],
toDate: ['']
});
this.loadLetterhead();
}
/** Institution Profile is the bank's own single record; Branch Profile is per-office. There is no
* office id on the current account data reaching this tab, and this deployment runs a single
* branch, so the first branch profile on record is the correct one here — a genuinely
* multi-branch deployment would need the account's own office id threaded through instead. */
private loadLetterhead(): void {
forkJoin({
institution: this.organizationService.getInstitutionProfile().pipe(catchError(() => of(null))),
branches: this.organizationService.getBranchProfiles().pipe(catchError(() => of([])))
}).subscribe(({ institution, branches }: any) => {
if (institution) {
this.bankName = institution.passbookHeaderName || institution.bankName || institution.legalName || 'Bank';
this.bankSubtitle = institution.licenseNumber
? `Lic. No. ${institution.licenseNumber}`
: 'Current Account Statement';
}
const branch = Array.isArray(branches) ? branches[0] : null;
if (branch) {
this.branchName = branch.officeName || '';
this.branchAddress = branch.address || '';
this.ifscCode = branch.ifscCode || '';
this.micrCode = branch.micrCode || '';
}
});
}
private computeRunningBalances(transactions: CurrentAccountTransaction[]): CurrentAccountTransaction[] {
@@ -279,6 +319,18 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
margin-top: 3px;
}
/* Branch identification line */
.branch-line {
background: #e8edf5;
border-bottom: 1px solid #d0d7e3;
padding: 5px 20px;
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
font-size: 9.5px;
color: #4a5a7a;
}
/* Account details band */
.account-details-band {
background: #f0f3f9;
@@ -334,17 +334,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
if (this.principalAmount && this.principalAmount > 0) {
// Convert tenure to months for backend API. A Days tenure under 30 days rounds down to 0
// months, which the backend rejects -- send it as tenureInDays instead in that case.
// Years and Months convert exactly; a Days tenure is sent as days and never converted.
let tenureInMonths: number | undefined;
let tenureInDays: number | undefined;
if (this.tenurePeriodUnit === 'Years') {
tenureInMonths = this.tenureValue * 12;
} else if (this.tenurePeriodUnit === 'Months') {
tenureInMonths = this.tenureValue;
} else if (enteredDays >= 30) {
tenureInMonths = Math.floor(enteredDays / 30);
} else {
// 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;
}
@@ -423,10 +424,17 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
} else {
const n = 12 / compMonths;
const effectiveMonths = tenureInMonths ?? enteredDays / 30;
const totalCompoundings = Math.floor(effectiveMonths / compMonths);
const rPerCompounding = rate / (n * 100);
result.maturityAmount =
Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 100) / 100;
// Whole compounding periods, then simple interest on the days left over. Flooring the
// periods and stopping there dropped the remainder entirely: a 200-day deposit
// compounding monthly quoted only six months of interest and lost the other twenty
// days, which is the depositor's money.
const wholePeriods = Math.floor(effectiveMonths / compMonths);
const compounded = this.principalAmount * Math.pow(1 + rPerCompounding, wholePeriods);
const leftoverDays = enteredDays - wholePeriods * compMonths * 30;
const leftoverInterest =
leftoverDays > 0 ? (compounded * rate * leftoverDays) / (100 * daysInYearVal) : 0;
result.maturityAmount = Math.round((compounded + leftoverInterest) * 100) / 100;
}
result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100;
this.matchResult = { ...result };
@@ -18,7 +18,7 @@
{{
isDashboardRenewalMode
? 'Renew ' + (dashboardRenewalRow?.accountNo ?? 'this matured FD') + '.'
: "Process this Fixed Deposit now -- works whether it's Active or Matured."
: 'Process this matured Fixed Deposit now.'
}}
</p>
</div>
@@ -40,18 +40,64 @@
</div>
} @else {
<form [formGroup]="paymentForm">
@if (isDashboardRenewalMode) {
@if (isMaturedRenewal) {
<p class="pmt__hint">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
{{ dashboardRenewalTimingText }}
{{ maturedRenewalTimingText }}
</p>
@if (renewalPreviewLoading) {
<p class="pmt__hint">
<fa-icon icon="spinner" [class.fa-spin]="true" class="m-r-5"></fa-icon>
Calculating gap-period interest...
</p>
} @else if (renewalPreview && !renewalPreview.withinGrace) {
@if (!renewalPreview.linkedAccountId) {
<p class="pmt__hint pmt__hint--warn" role="alert">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This FD has no Deposit Account Link, so there is no account to read a gap-interest rate from. Link a
Savings account to it before renewing late.
</p>
} @else {
<div class="pmt__preview">
<div class="pmt__preview-row">
<span class="pmt__preview-label">FD Maturity Balance (renews as-is below)</span>
<span class="pmt__preview-value">{{ renewalPreview.currentBalance | formatNumber }}</span>
</div>
<div class="pmt__preview-row">
<span class="pmt__preview-label">Gap Days ({{ renewalPreview.maturityDate }} &rarr; today)</span>
<span class="pmt__preview-value">{{ renewalPreview.gapDays }}</span>
</div>
<div class="pmt__preview-row">
<span class="pmt__preview-label">Deposit Account Link</span>
<span class="pmt__preview-value">{{ renewalPreview.linkedAccountNo }}</span>
</div>
<div class="pmt__preview-row">
<span class="pmt__preview-label">Its Interest Rate</span>
<span class="pmt__preview-value">{{ renewalPreview.linkedAccountRate }}%</span>
</div>
<div class="pmt__preview-row pmt__preview-row--highlight pmt__preview-row--total">
<span class="pmt__preview-label"
>Gap-Period Interest &rarr; paid to {{ renewalPreview.linkedAccountNo }}</span
>
<span class="pmt__preview-value">+ {{ renewalPreview.gapInterestAmount | formatNumber }}</span>
</div>
</div>
<p class="pmt__hint">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This gap interest was earned at the linked account's own rate, not the FD's -- it is credited straight
to account {{ renewalPreview.linkedAccountNo }} (the FD's Deposit Account Link), separately from
whatever you choose below. The FD itself renews from its own maturity balance, unchanged.
</p>
}
}
}
@if (reinvestCap !== null && reinvestCap !== undefined) {
<div class="pmt__stat">
<span class="pmt__stat-label">
{{
isDashboardRenewalMode || !remainderIsApproximate
isMaturedRenewal || !remainderIsApproximate
? ('labels.inputs.Maturity Amount' | translate)
: 'Amount available on the selected date'
}}
@@ -60,7 +106,7 @@
</div>
}
@if (!isDashboardRenewalMode) {
@if (!isMaturedRenewal) {
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
@@ -184,7 +230,7 @@
</mat-form-field>
}
@if (!isDashboardRenewalMode) {
@if (!isMaturedRenewal) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
@@ -164,6 +164,68 @@
}
}
.pmt__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 {
.pmt__preview-value {
color: #0d9488;
font-weight: 800;
}
}
&--total {
margin: 0 -18px;
padding: 14px 18px;
background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%);
border-bottom: none;
border-radius: 0 0 12px 12px;
.pmt__preview-label {
color: #1d4ed8;
font-weight: 700;
}
.pmt__preview-value {
color: #0f172a;
font-size: 18px;
font-weight: 800;
}
}
}
&-label {
font-size: 12.5px;
color: #64748b;
font-weight: 600;
}
&-value {
font-family: 'JetBrains Mono', 'DM Sans', monospace;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
}
.pmt__section-label {
font-size: 12px;
font-weight: 700;
@@ -113,6 +113,12 @@ export class PaymentDialogComponent implements OnInit {
* after open; cleared as soon as `submit()` has credited it, so a retried submit doesn't double-post. */
private gapInterestPending = false;
/** Read-only numbers (current balance, linked account's rate, gap days, gap-interest amount,
* projected new balance) fetched once on open in dashboard-renewal mode, shown above the Action
* cards so staff see exactly what a late renewal is about to credit before confirming anything. */
renewalPreview: any = null;
renewalPreviewLoading = false;
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.paymentForm = this.formBuilder.group({
@@ -127,16 +133,32 @@ export class PaymentDialogComponent implements OnInit {
note: ['']
});
const dashboardRow = this.dashboardRenewalRow;
if (dashboardRow) {
// 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 in this mode (see the template), so it can't be moved off this value.
this.paymentForm
.get('closedOnDate')
.setValue(dashboardRow.withinGraceWindow ? new Date(dashboardRow.maturityDate) : this.maxDate);
this.gapInterestPending = !dashboardRow.withinGraceWindow;
}
// Fetched every time this dialog opens, not just from the FD Dashboard -- Payment is now only ever
// offered once an FD has Matured (see FixedDepositsButtonsConfiguration), so a plain Payment on it
// is exactly the same "late renewal" scenario the Dashboard's Renew action handles, and needs the
// exact same date-locking/gap-interest treatment, or an FD closed this way instead of from the
// Dashboard would silently skip crediting interest it genuinely earned.
this.renewalPreviewLoading = true;
this.fixedDepositsService.getFdRenewalPreview(this.data.accountId).subscribe({
next: (preview: any) => {
this.renewalPreview = preview;
this.renewalPreviewLoading = false;
if (preview?.isMatured) {
// Computed from the grace window, never picked -- the date field stays in the form (the
// generic submit path and reinvestCap/processingOnOrAfterMaturity both key off it) but its
// picker is hidden once matured (see the template), so it can't be moved off this value.
this.paymentForm
.get('closedOnDate')
.setValue(preview.withinGrace ? new Date(preview.maturityDate) : this.maxDate);
this.gapInterestPending = !preview.withinGrace;
}
},
// Non-fatal -- Confirm still works from here (backend re-derives the same numbers on submit),
// only the numeric preview and its date-locking are unavailable, not the action itself.
error: () => {
this.renewalPreviewLoading = false;
}
});
this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
// The cap depends on whether the chosen date is on or after maturity, so a date change can make an
@@ -176,13 +198,19 @@ export class PaymentDialogComponent implements OnInit {
return this.data.dashboardRenewal?.row ?? null;
}
/** What clicking Confirm is actually about to do -- shown above the (hidden-picker) date in this
* mode, since the date itself is no longer visible to explain it. */
get dashboardRenewalTimingText(): string {
const row = this.dashboardRenewalRow;
if (!row) return '';
return row.withinGraceWindow
? `Backdated to its original maturity date (${row.maturityDate}), at the currently active interest rate.`
/** 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.`;
}
@@ -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 { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component';
import { PrematureCloseDialogComponent } from './custom-dialogs/premature-close-dialog/premature-close-dialog.component';
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
/** Custom Button Config. */
@@ -238,7 +239,6 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Undo Activation':
case 'Add Charge':
case 'Withdrawn by Client':
case 'Premature Close':
case 'Deposit':
case 'Withdrawal':
this.router.navigate([`actions/${name}`], { relativeTo: this.route });
@@ -267,12 +267,17 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Payment':
this.openPaymentDialog();
break;
case 'Premature Close':
this.openPrematureCloseDialog();
break;
}
}
/**
* Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or
* Matured, and is the only place a Partial Reinvest can be done.
* Opens the Payment action -- only offered once the FD has actually Matured (see
* FixedDepositsButtonsConfiguration), and the only place a Partial Reinvest can be done. Also the
* exact same dialog the FD Dashboard's Renew action opens (see fd-dashboard.component.ts's
* openRenewDialog), just without dashboardRenewal data, so its date picker stays visible.
*/
private openPaymentDialog(): void {
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
@@ -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.
*/
@@ -110,7 +110,18 @@ export class FixedDepositsButtonsConfiguration {
setOptions(status: string) {
switch (status) {
case 'Active':
this.optionArray = [
{
name: 'Post Interest'
},
{
name: 'Add Charge'
}
];
break;
case 'Matured':
// Payment only makes sense once the deposit has actually matured -- an Active FD that needs
// closing early already has Premature Close for that.
this.optionArray = [
{
name: 'Post Interest'
@@ -24,9 +24,6 @@
@if (actions['Add Charge']) {
<mifosx-add-charge-fixed-deposits-account></mifosx-add-charge-fixed-deposits-account>
}
@if (actions['Premature Close']) {
<mifosx-premature-close-fixed-deposits-account></mifosx-premature-close-fixed-deposits-account>
}
@if (actions['Close']) {
<mifosx-close-fixed-deposits-account></mifosx-close-fixed-deposits-account>
}
@@ -15,7 +15,6 @@ import { ActivateFixedDepositsAccountComponent } from './activate-fixed-deposits
import { UndoApprovalFixedDepositsAccountComponent } from './undo-approval-fixed-deposits-account/undo-approval-fixed-deposits-account.component';
import { WithdrawByClientFixedDepositsAccountComponent } from './withdraw-by-client-fixed-deposits-account/withdraw-by-client-fixed-deposits-account.component';
import { AddChargeFixedDepositsAccountComponent } from './add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component';
import { PrematureCloseFixedDepositsAccountComponent } from './premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component';
import { CloseFixedDepositsAccountComponent } from './close-fixed-deposits-account/close-fixed-deposits-account.component';
import { FixedDepositsCashTransactionComponent } from './fixed-deposits-cash-transaction/fixed-deposits-cash-transaction.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -35,7 +34,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
UndoApprovalFixedDepositsAccountComponent,
WithdrawByClientFixedDepositsAccountComponent,
AddChargeFixedDepositsAccountComponent,
PrematureCloseFixedDepositsAccountComponent,
CloseFixedDepositsAccountComponent,
FixedDepositsCashTransactionComponent
]
@@ -52,7 +50,6 @@ export class FixedDepositsAccountActionsComponent {
'Undo Approval': boolean;
'Undo Activation': boolean;
'Add Charge': boolean;
'Premature Close': boolean;
'Withdrawn by Client': boolean;
Withdrawal: boolean;
Deposit: boolean;
@@ -64,7 +61,6 @@ export class FixedDepositsAccountActionsComponent {
'Undo Approval': false,
'Undo Activation': false,
'Add Charge': false,
'Premature Close': false,
'Withdrawn by Client': false,
Withdrawal: false,
Deposit: false
@@ -1,105 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card>
<form [formGroup]="prematureCloseAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Premature Close Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (prematureCloseAccountForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Premature Close Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (prematureCloseAccountForm.controls.maturityAmount) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Maturity Amount' | translate }}</mat-label>
<input type="number" matInput formControlName="maturityAmount" />
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('onAccountClosureId')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Action' | translate }}</mat-label>
<mat-select required formControlName="onAccountClosureId">
@for (closureOption of onAccountClosureOptions; track closureOption) {
<mat-option [value]="closureOption.id">
{{ closureOption.value }}
</mat-option>
}
</mat-select>
@if (prematureCloseAccountForm.controls.onAccountClosureId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('toSavingsAccountId')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
{{ account.accountNo }}
</mat-option>
}
</mat-select>
@if (prematureCloseAccountForm.controls.toSavingsAccountId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('transferDescription')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
}
@if (prematureCloseAccountForm.contains('note')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
}
</div>
</mat-card-content>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="warn" [disabled]="!prematureCloseAccountForm.valid">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
</div>
@@ -1,11 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
max-width: 37rem;
}
@@ -1,179 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import {
UntypedFormGroup,
UntypedFormBuilder,
Validators,
UntypedFormControl,
ReactiveFormsModule
} from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */
import { FixedDepositsService } from '../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Premature Close Fixed Deposits Account Component
*/
@Component({
selector: 'mifosx-premature-close-fixed-deposits-account',
templateUrl: './premature-close-fixed-deposits-account.component.html',
styleUrls: ['./premature-close-fixed-deposits-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CdkTextareaAutosize
]
})
export class PrematureCloseFixedDepositsAccountComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private dateUtils = inject(Dates);
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
maxDate = new Date();
/** Premature Close FD Account form. */
prematureCloseAccountForm: UntypedFormGroup;
/** Savings Account Data */
savingsAccountsData: any;
/** On account Closure Options */
onAccountClosureOptions: any;
/** Fixed Deposits Account Id */
accountId: any;
/** Form submission event */
isSubmitted = false;
/**
* @param {FormBuilder} formBuilder Form Builder
* @param {FixedDepositsService} fixedDepositsService Fixed Deposits Service
* @param {Dates} dateUtils Date Utils
* @param {ActivatedRoute} route Activated Route
* @param {Router} router Router
* @param {SettingsService} settingsService Settings Service
*/
constructor() {
this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId'];
}
/**
* Creates the premature close fd account form.
*/
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createPrematureCloseAccountForm();
this.buildDependencies();
}
/**
* Creates the premature close fd account form.
*/
createPrematureCloseAccountForm() {
this.prematureCloseAccountForm = this.formBuilder.group({
closedOnDate: [
'',
Validators.required
]
});
}
/**
* Subscribes to value changes of parent control.
*/
buildDependencies() {
this.prematureCloseAccountForm.get('closedOnDate').valueChanges.subscribe((value: Date) => {
if (!this.isSubmitted) {
this.calculatePrematureAmount(value);
}
});
}
/**
* Calculates prematue amount based on closed on date.
* @param {Date} date Premature Close Date
*/
calculatePrematureAmount(date: Date) {
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const data = {
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'calculatePrematureAmount', data)
.subscribe((response: any) => {
this.savingsAccountsData = response.savingsAccounts;
this.onAccountClosureOptions = response.onAccountClosureOptions;
this.prematureCloseAccountForm.addControl(
'maturityAmount',
new UntypedFormControl({ value: '', disabled: true })
);
this.prematureCloseAccountForm.addControl(
'onAccountClosureId',
new UntypedFormControl('', Validators.required)
);
this.prematureCloseAccountForm.addControl('note', new UntypedFormControl(''));
this.prematureCloseAccountForm.get('maturityAmount').patchValue(response.maturityAmount);
this.addTransferDetails();
});
}
/**
* Subscribes to value changes of `onAccountClosureId` adds and removes transfer details accordingly.
*/
addTransferDetails() {
this.prematureCloseAccountForm.get('onAccountClosureId').valueChanges.subscribe((id: any) => {
if (id === 200) {
this.prematureCloseAccountForm.addControl(
'toSavingsAccountId',
new UntypedFormControl('', Validators.required)
);
this.prematureCloseAccountForm.addControl('transferDescription', new UntypedFormControl(''));
} else {
this.prematureCloseAccountForm.removeControl('toSavingsAccountId');
this.prematureCloseAccountForm.removeControl('transferDescription');
}
});
}
/**
* Submits the form and premature closes the fd account,
* if successful redirects to the fd account.
*/
submit() {
const prematureCloseAccountFormData = this.prematureCloseAccountForm.value;
this.isSubmitted = true;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const prevClosedDate: Date = this.prematureCloseAccountForm.value.closedOnDate;
if (prematureCloseAccountFormData.closedOnDate instanceof Date) {
prematureCloseAccountFormData.closedOnDate = this.dateUtils.formatDate(prevClosedDate, dateFormat);
}
const data = {
...prematureCloseAccountFormData,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'prematureClose', data)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
}
}
@@ -35,7 +35,6 @@ import { ViewTransactionComponent } from './fixed-deposit-account-view/view-tran
import { WaiveChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/waive-charge-dialog/waive-charge-dialog.component';
import { InactivateChargeDialogComponent } from './fixed-deposit-account-view/custom-dialogs/inactivate-charge-dialog/inactivate-charge-dialog.component';
import { AddChargeFixedDepositsAccountComponent } from './fixed-deposits-account-actions/add-charge-fixed-deposits-account/add-charge-fixed-deposits-account.component';
import { PrematureCloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/premature-close-fixed-deposits-account/premature-close-fixed-deposits-account.component';
import { CloseFixedDepositsAccountComponent } from './fixed-deposits-account-actions/close-fixed-deposits-account/close-fixed-deposits-account.component';
import { CreateFixedDepositAccountComponent } from './create-fixed-deposit-account/create-fixed-deposit-account.component';
import { FixedDepositAccountDetailsStepComponent } from './fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component';
@@ -78,7 +77,6 @@ import { FixedDepositsCashTransactionComponent } from './fixed-deposits-account-
WaiveChargeDialogComponent,
InactivateChargeDialogComponent,
AddChargeFixedDepositsAccountComponent,
PrematureCloseFixedDepositsAccountComponent,
CloseFixedDepositsAccountComponent,
CreateFixedDepositAccountComponent,
FixedDepositAccountDetailsStepComponent,
@@ -174,6 +174,13 @@ export class FixedDepositsService {
return this.http.post(`/fixeddepositaccounts/${accountId}/credit-gap-interest`, {});
}
/** Read-only -- what a Renew is about to do (current balance, linked account's rate, gap days,
* gap-interest amount, projected new balance), shown in the Payment dialog before anything is
* confirmed. Posts nothing; safe to call as often as the dialog needs. */
getFdRenewalPreview(accountId: string | number): Observable<any> {
return this.http.get(`/fixeddepositaccounts/${accountId}/renewal-preview`);
}
/**
* @param fixedDepositAccount Fixed Deposit Account
* @returns {Observable<any>}
@@ -181,15 +181,15 @@
</tr>
</thead>
<tbody>
@for (r of visibleRows; track r.accountId) {
<tr [class.dr-row--warn]="isNearingEscheat(r)" [class.dr-row--sel]="selected.has(r.accountId)">
@for (r of visibleRows; track r.accountType + '-' + r.accountId) {
<tr [class.dr-row--warn]="isNearingEscheat(r)" [class.dr-row--sel]="isSelected(r)">
@if (tab === 'ESCHEAT') {
<td class="dr-tick">
<!-- An already-remitted balance cannot be remitted again. -->
<input
type="checkbox"
[checked]="selected.has(r.accountId)"
[disabled]="r.remittance === 'REMITTED'"
[checked]="isSelected(r)"
[disabled]="!isRemittable(r)"
(click)="toggleSelect(r, $event)"
/>
</td>
@@ -203,7 +203,12 @@
<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>
@@ -147,8 +147,24 @@ export class DormancyReportComponent implements OnInit {
remitSaving = false;
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 {
event.stopPropagation();
if (!this.isRemittable(row)) {
return;
}
if (this.selected.has(row.accountId)) {
this.selected.delete(row.accountId);
} else {
@@ -158,7 +174,7 @@ export class DormancyReportComponent implements OnInit {
/** Ticks every unremitted row on display, or clears them if all are already ticked. */
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))) {
selectable.forEach((r) => this.selected.delete(r.accountId));
} else {
@@ -177,12 +193,12 @@ export class DormancyReportComponent implements OnInit {
}
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));
}
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 {
@@ -316,6 +332,7 @@ export class DormancyReportComponent implements OnInit {
const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' };
return {
accountType: a?.accountType || 'SAVINGS',
accountId: a?.accountId,
accountNo: a?.accountNo || '—',
clientId: a?.clientId,
@@ -80,6 +80,8 @@ export const DORMANCY_STAGES: Record<DormancyStage, DormancyStageMeta> = {
export type DeafRemittanceState = 'PENDING' | 'REMITTED';
export interface DormancyRow {
/** SAVINGS or CURRENT. Ids are only unique within one kind, so rows are keyed by both. */
accountType: string;
accountId: number;
accountNo: string;
clientId: number;
@@ -34,6 +34,7 @@ import {
ageInFy,
buildSchedule,
checkEligibility,
configuredFys,
declarationFormFor,
formKindFor,
isSeniorInFy,
@@ -134,7 +135,10 @@ export class Form15ghComponent implements OnInit {
// 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
// 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 {
@@ -219,13 +223,13 @@ export class Form15ghComponent implements OnInit {
* than written into the template.
*/
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];
return {
form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
section: cfg.interestTdsSection,
statement: cfg.tdsStatementForm,
frequency: cfg.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
form: cfg?.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
section: cfg?.interestTdsSection ?? '—',
statement: cfg?.tdsStatementForm ?? '—',
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
};
}
@@ -41,6 +41,7 @@ import {
buildSchedule,
checkEligibility,
collectTdsCredits,
configuredFys,
declarationFormFor,
formKindFor,
isSeniorInFy,
@@ -115,18 +116,15 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy {
/** Which row's quarter-wise breakdown is open. */
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
private clientDataCache = new Map<string, Observable<any>>();
private clientKycCache = new Map<string, Observable<any>>();
private clientAccountsCache = new Map<string, Observable<any>>();
readonly availableFys = [
'FY 2026-27',
'FY 2025-26'
];
ngOnInit(): void {
this.loadRealData();
}
@@ -137,6 +135,12 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy {
}
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.loadError = false;
this.clientDataCache.clear();
@@ -30,6 +30,9 @@ import {
tdsDeducteeOf,
tdsModeFor
} from './tds-15gh.compute';
import { installTdsConfigFixture } from './tds-config.fixture';
installTdsConfigFixture();
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
* `nilTaxCeiling` are lower and vary by age; pass an override through
* `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> = {
'2025-26': {
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
},
'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
export const FY_TAX_CONFIG: Record<string, FyTaxConfig> = {};
/** Bumped on every load, so memoised computations can tell one configuration from the next. */
export let fyTaxConfigVersion = 0;
/** Replaces the whole registry with what the platform holds. */
export function setFyTaxConfigs(configs: FyTaxConfig[]): void {
for (const key of Object.keys(FY_TAX_CONFIG)) {
delete FY_TAX_CONFIG[key];
}
};
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 ───────────────────────
@@ -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 { Form60Component } from './certificates/form-60/form-60.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 { NocCertificateComponent } from './certificates/noc/noc.component';
import { DatabaseExportComponent } from './database-export/database-export.component';
@@ -68,6 +69,7 @@ const routes: Routes = [
{
path: 'letter-mgmt/form-15gh',
component: Form15ghComponent,
resolve: { tdsConfig: tdsConfigResolver },
data: { title: 'Form 121', breadcrumb: 'Form 121' }
},
{
+1 -1
View File
@@ -438,7 +438,7 @@ export class LoginComponent implements OnInit, OnDestroy {
.split(',')
.map((id) => id.trim())
.filter((id) => id.length > 0);
if (tenantIds.length === 0 || (tenantIds.length === 1 && tenantIds[0] === 'default')) {
if (tenantIds.length === 0) {
return false;
}
return true;
@@ -143,11 +143,25 @@ export class BranchProfileComponent implements OnInit {
.subscribe((res: any) => {
this.saving = false;
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.form.markAsPristine();
this.lastUpdated = res.updatedByUsername ? { by: res.updatedByUsername, at: res.updatedAt } : null;
const idx = this.offices.findIndex((o) => o.officeId === this.selectedOfficeId);
if (idx >= 0) this.offices[idx] = res;
// The response is now a CommandProcessingResult, not the updated row (the write goes through
// the command pipeline) - reload the saved row from the server instead of patching state from
// 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 { MatGridList, MatGridTile } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Manage Currencies component.
@@ -68,6 +69,7 @@ export class ManageCurrenciesComponent implements OnInit, AfterViewInit, OnDestr
private translateService = inject(TranslateService);
private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService);
private alertService = inject(AlertService);
//** Defining PlaceHolders for the search bar */
placeHolderLabel = '';
@@ -164,8 +166,17 @@ export class ManageCurrenciesComponent implements OnInit, AfterViewInit, OnDestr
if (!selectedCurrencyCodes.includes(newCurrency.code)) {
selectedCurrencyCodes.push(newCurrency.code);
this.organizationservice.updateCurrencies(selectedCurrencyCodes).subscribe((response: any) => {
this.selectedCurrencies.push(newCurrency);
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) {
this.configurationWizardService.showCurrencyForm = false;
this.openDialog();
@@ -187,9 +198,18 @@ export class ManageCurrenciesComponent implements OnInit, AfterViewInit, OnDestr
});
deleteCurrencyDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.organizationservice.updateCurrencies(selectedCurrencyCodes).subscribe(() => {
this.selectedCurrencies.splice(index, 1);
this.organizationservice.updateCurrencies(selectedCurrencyCodes).subscribe((updateResponse: any) => {
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) => {
this.saving = false;
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.applyResult(res);
this.form.markAsPristine();
// The response is now a CommandProcessingResult, not the updated row (the write goes through
// 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 { OrganizationService } from 'app/organization/organization.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
@Component({
selector: 'mifosx-create-fund',
@@ -25,6 +26,7 @@ export class CreateFundComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private router = inject(Router);
private route = inject(ActivatedRoute);
private alertService = inject(AlertService);
/** Charge form. */
fundForm: UntypedFormGroup;
@@ -49,6 +51,12 @@ export class CreateFundComponent implements OnInit {
submit() {
const payload = this.fundForm.getRawValue();
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 });
});
}
@@ -261,6 +261,32 @@ const routes: Routes = [
import('./clearing-gl-config/clearing-gl-config.component').then((m) => m.ClearingGlConfigComponent),
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',
data: { title: 'Manage Employees', breadcrumb: 'Manage Employees' },
@@ -76,6 +76,64 @@
</a>
</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 -->
<div #addEditCurrency>
<a class="org-card" [routerLink]="['currencies']" *mifosxHasPermission="'READ_CURRENCY'">
@@ -935,4 +935,71 @@ export class OrganizationService {
updateClearingGlConfig(configKey: string, glAccountId: number | null): Observable<any> {
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 { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Create Payment Type Component.
@@ -35,6 +36,7 @@ export class CreatePaymentTypeComponent implements OnInit {
private organizationService = inject(OrganizationService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private alertService = inject(AlertService);
/** Payment Type form. */
paymentTypeForm: UntypedFormGroup;
@@ -73,7 +75,13 @@ export class CreatePaymentTypeComponent implements OnInit {
*/
submit() {
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 });
});
}
@@ -16,6 +16,7 @@ import { OrganizationService } from 'app/organization/organization.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Edit Payment Type component.
@@ -35,6 +36,7 @@ export class EditPaymentTypeComponent implements OnInit {
private organizationService = inject(OrganizationService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private alertService = inject(AlertService);
/** Payment Type form. */
paymentTypeForm: UntypedFormGroup;
@@ -88,7 +90,13 @@ export class EditPaymentTypeComponent implements OnInit {
*/
submit() {
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 });
});
}
@@ -34,6 +34,7 @@ import { DeleteDialogComponent } from '../../shared/delete-dialog/delete-dialog.
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AlertService } from 'app/core/alert/alert.service';
/**
* Payment Types component.
@@ -65,6 +66,7 @@ export class PaymentTypesComponent implements OnInit {
private organizationService = inject(OrganizationService);
private route = inject(ActivatedRoute);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
/** Payment Types data. */
paymentTypesData: any;
@@ -132,9 +134,20 @@ export class PaymentTypesComponent implements OnInit {
});
deletePaymentTypeDialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
this.organizationService.deletePaymentType(paymentTypeId).subscribe(() => {
this.paymentTypesData = this.paymentTypesData.filter((paymentType: any) => paymentType.id !== paymentTypeId);
this.setPaymentTypes();
this.organizationService.deletePaymentType(paymentTypeId).subscribe((deleteResponse: any) => {
if (deleteResponse?.rollbackTransaction) {
// 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 { PopoverService } from '../../configuration-wizard/popover/popover.service';
import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service';
import { AlertService } from 'app/core/alert/alert.service';
/** Custom Dialog Components */
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 configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService);
private alertService = inject(AlertService);
/** Working days form. */
workingDaysForm: UntypedFormGroup;
@@ -152,7 +154,13 @@ export class WorkingDaysComponent implements OnInit, AfterViewInit {
}
}
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) {
this.configurationWizardService.showDefineWorkingDays = false;
this.openNextStepDialog();
@@ -50,6 +50,7 @@
<div class="cp-tab-content">
<mifosx-current-product-terms-step
[CurrentProductsTemplate]="currentProductsTemplate"
[allowOverdraft]="settingsForm.get('allowOverdraft')"
></mifosx-current-product-terms-step>
</div>
</mat-tab>
@@ -127,15 +127,11 @@ export class CurrentProductAccountingStepComponent implements OnInit {
private patchAccountingValues(mappings: any) {
this.CurrentProductAccountingForm.patchValue({
savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '',
savingsControlAccountId: mappings.savingsControlAccount?.id || '',
transfersInSuspenseAccountId: mappings.transfersInSuspenseAccount?.id || '',
interestOnSavingsAccountId: mappings.interestOnSavingsAccount?.id || '',
incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '',
incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '',
incomeFromInterestId: mappings.incomeFromInterest?.id || '',
overdraftPortfolioControlId: mappings.overdraftPortfolioControl?.id || '',
writeOffAccountId: mappings.writeOffAccount?.id || '',
feesReceivableAccountId: mappings.feeReceivableAccount?.id || '',
penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '',
interestPayableAccountId: mappings.interestPayableAccount?.id || '',
@@ -267,14 +263,16 @@ export class CurrentProductAccountingStepComponent implements OnInit {
};
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('transfersInSuspenseAccountId', true);
addControl('interestOnSavingsAccountId', true);
addControl('incomeFromFeeAccountId', true);
addControl('incomeFromPenaltyAccountId', true);
addControl('incomeFromInterestId', true);
addControl('writeOffAccountId', true);
addControl('overdraftPortfolioControlId', false);
if (this.accounting.isAccrualAccountingRuleId(accountingRule)) {
@@ -297,12 +295,9 @@ export class CurrentProductAccountingStepComponent implements OnInit {
} else {
removeControl('savingsReferenceAccountId');
removeControl('savingsControlAccountId');
removeControl('transfersInSuspenseAccountId');
removeControl('interestOnSavingsAccountId');
removeControl('incomeFromFeeAccountId');
removeControl('incomeFromPenaltyAccountId');
removeControl('incomeFromInterestId');
removeControl('writeOffAccountId');
removeControl('overdraftPortfolioControlId');
removeControl('feesReceivableAccountId');
removeControl('penaltiesReceivableAccountId');
@@ -345,14 +340,6 @@ export class CurrentProductAccountingStepComponent implements OnInit {
pillLabel: 'Asset',
pillClass: 'green',
rows: [
{
key: 'savingsReferenceAccountId',
label: 'Savings Reference Account',
inputLabel: 'Savings reference account',
controlName: 'savingsReferenceAccountId',
options: this.assetAccountOptions,
required: true
},
{
key: 'overdraftPortfolioControlId',
label: 'Overdraft Portfolio Control',
@@ -396,20 +383,12 @@ export class CurrentProductAccountingStepComponent implements OnInit {
rows: [
{
key: 'savingsControlAccountId',
label: 'Savings Control Account',
inputLabel: 'Savings control account',
label: 'Current Control Account',
inputLabel: 'Current control account',
controlName: 'savingsControlAccountId',
options: this.liabilityAccountOptions,
required: true
},
{
key: 'transfersInSuspenseAccountId',
label: 'Transfers In Suspense Account',
inputLabel: 'Transfers in suspense account',
controlName: 'transfersInSuspenseAccountId',
options: this.liabilityAccountOptions,
required: true
},
{
key: 'interestPayableAccountId',
label: 'Interest Payable Account',
@@ -430,32 +409,8 @@ export class CurrentProductAccountingStepComponent implements OnInit {
otherMappingsControlName: 'liabilitiesOtherGlMappings',
otherAccountOptions: this.liabilityAccountOptions
},
{
key: 'expenses',
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
},
// No Expenses section: a current account pays no interest and has no write-off, so it maps no expense GL.
// The expensesOtherGlMappings control is kept, so an older product's saved expense mappings survive an edit.
{
key: 'income',
title: 'Income',
@@ -162,29 +162,16 @@
}}</span>
</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">
<span class="flex-40">Auto Interest Post:</span>
<span class="flex-60">{{ CurrentProduct.autoPost | yesNo }}</span>
</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>
<mat-divider class="flex-100"></mat-divider>
@@ -282,39 +269,58 @@
</div>
@if (CurrentProduct.allowOverdraft) {
@if (CurrentProduct.avgBalanceCalcPeriod) {
<div class="flex-100 layout-row">
<span class="flex-40">Avg Balance Calculation Period:</span>
<span class="flex-60">{{ CurrentProduct.avgBalanceCalcPeriod }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Maximum OD Limit:</span>
<span class="flex-60">₹ {{ CurrentProduct.overdraftLimit | formatNumber }}</span>
</div>
@if (CurrentProduct.validForPeriod) {
@if (CurrentProduct.odMinLimit) {
<div class="flex-100 layout-row">
<span class="flex-40">Valid for Period:</span>
<span class="flex-60">{{ CurrentProduct.validForPeriod }}</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>
<span class="flex-40">Minimum OD Limit:</span>
<span class="flex-60">₹ {{ CurrentProduct.odMinLimit | formatNumber }}</span>
</div>
}
<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>
</div>
}
@@ -453,15 +459,6 @@
<div class="spa-gl-section">
<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">
<span class="spa-gl-row__label">Overdraft portfolio control</span>
<span class="spa-gl-row__value">{{
@@ -503,7 +500,7 @@
<div class="spa-gl-section">
<h5 class="mat-h5">Liabilities</h5>
<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">{{
formatGlAccountById(
CurrentProduct.savingsControlAccountId,
@@ -511,15 +508,6 @@
)
}}</span>
</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">
<span class="spa-gl-row__label">Interest payable account</span>
<span class="spa-gl-row__value">{{
@@ -542,28 +530,6 @@
}
</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">
<h5 class="mat-h5">Income</h5>
<div class="spa-gl-row">
@@ -110,72 +110,126 @@
<span>Overdraft (OD) Facility</span>
</div>
<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">
<!-- Left: checkboxes -->
<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) {
<div class="sps-od-check-row">
<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>
<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>
</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) {
<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">
<mat-form-field appearance="outline" class="sps-field">
<mat-label>OD Limit %</mat-label>
<mat-label>Maximum OD Limit (₹)</mat-label>
<input
type="number"
matInput
formControlName="overdraftLimit"
placeholder="e.g. 15"
min="0"
step="0.01"
placeholder="e.g. 500000"
min="1"
required
/>
<mat-hint>Highest per account</mat-hint>
<mat-error>Enter the maximum OD limit</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>Interest on OD %</mat-label>
<input
type="number"
matInput
formControlName="nominalAnnualInterestRateOverdraft"
placeholder="e.g. 8.5"
min="0"
step="0.01"
/>
<mat-label>Minimum OD Limit (₹)</mat-label>
<input type="number" matInput formControlName="odMinLimit" placeholder="e.g. 10000" min="0" />
<mat-hint>Lowest per account</mat-hint>
@if (CurrentProductSettingsForm.get('odMinLimit')?.hasError('exceedsMax')) {
<mat-error>Cannot exceed the maximum OD limit</mat-error>
} @else {
<mat-error>Cannot be negative</mat-error>
}
</mat-form-field>
</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>
@@ -7,7 +7,7 @@
*/
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 { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -40,6 +40,21 @@ export class CurrentProductSettingsStepComponent implements OnInit {
{ 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() {
this.createCurrentProductSettingsForm();
this.setConditionalControls();
@@ -72,14 +87,15 @@ export class CurrentProductSettingsStepComponent implements OnInit {
daysToEscheat: isEditing
? this.CurrentProductsTemplate.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,
overdraftLimit: isEditing ? this.CurrentProductsTemplate.overdraftLimit : '',
nominalAnnualInterestRateOverdraft: isEditing
? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft
: '',
avgBalanceCalcPeriod: isEditing ? this.CurrentProductsTemplate.avgBalanceCalcPeriod || '1Y' : '1Y',
validForPeriod: isEditing ? this.CurrentProductsTemplate.validForPeriod || '1Y' : '1Y',
odMinLimit: isEditing ? this.CurrentProductsTemplate.odMinLimit : '',
odSecurityType: isEditing ? this.CurrentProductsTemplate.odSecurityType || 'CLEAN' : 'CLEAN',
validForPeriod: this.validityOrDefault(isEditing ? this.CurrentProductsTemplate.validForPeriod : null),
odExcessAllowed: isEditing ? !!this.CurrentProductsTemplate.odExcessAllowed : false,
odExcessLimitPercent: isEditing ? this.CurrentProductsTemplate.odExcessLimitPercent : '',
odPenalRate: isEditing ? this.CurrentProductsTemplate.odPenalRate : '',
odAccountClosedZeroBalanceInDayClosing: isEditing
? (this.CurrentProductsTemplate.odAccountClosedZeroBalanceInDayClosing ?? false)
: false
@@ -123,23 +139,123 @@ export class CurrentProductSettingsStepComponent implements OnInit {
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
// Overdraft (OD) Facility
// Overdraft (OD) Facility: the scheme an account's OD sanction is held to
allowOverdraft: [false],
overdraftLimit: [
'',
[Validators.min(0)]
],
nominalAnnualInterestRateOverdraft: [
'',
[Validators.min(0)]
],
avgBalanceCalcPeriod: ['1Y'],
overdraftLimit: [''],
odMinLimit: [''],
odSecurityType: ['CLEAN'],
validForPeriod: ['1Y'],
// Excess drawing past the sanctioned limit: off means it is refused
odExcessAllowed: [false],
odExcessLimitPercent: [''],
odPenalRate: [''],
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() {
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) => {
const freq = this.CurrentProductSettingsForm.get('lockinPeriodFrequency');
const type = this.CurrentProductSettingsForm.get('lockinPeriodFrequencyType');
@@ -168,10 +284,15 @@ export class CurrentProductSettingsStepComponent implements OnInit {
if (!formValue.allowOverdraft) {
delete formValue.overdraftLimit;
delete formValue.nominalAnnualInterestRateOverdraft;
delete formValue.avgBalanceCalcPeriod;
delete formValue.odMinLimit;
delete formValue.odSecurityType;
delete formValue.validForPeriod;
delete formValue.odAccountClosedZeroBalanceInDayClosing;
delete formValue.odExcessAllowed;
}
if (!formValue.allowOverdraft || !formValue.odExcessAllowed) {
delete formValue.odExcessLimitPercent;
delete formValue.odPenalRate;
}
return formValue;
@@ -13,20 +13,76 @@
<span>Interest On OD</span>
</div>
<div class="spt-body">
<mat-form-field appearance="outline" class="spt-field" style="max-width: 320px">
<mat-label>Interest Posting Frequency *</mat-label>
<mat-select formControlName="interestPostingPeriodType" required>
@for (item of interestPostingPeriodTypeData; track item) {
<mat-option [value]="item.id">
{{ item.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
<mat-error>
Interest Posting Frequency {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
@if (allowOverdraftControl) {
<div class="spt-toggle-card">
<mat-checkbox [formControl]="allowOverdraftControl">
<span>Is Overdraft Allowed</span>
</mat-checkbox>
</div>
}
@if (!isOverdraftAllowed) {
<div class="spt-note">
<span>ℹ️</span>
<span>Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab.</span>
</div>
}
<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__grid">
@@ -43,25 +99,17 @@
}
</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 class="spt-note">
<span>⚠️</span>
<span>Daily product-based interest accrual enabled — interest calculated EOD on daily closing balance.</span>
</div>
@if (isOverdraftAllowed) {
<div class="spt-note">
<span>ℹ️</span>
<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>
@@ -7,12 +7,21 @@
*/
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 { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
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({
selector: 'mifosx-current-product-terms-step',
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 {
private formBuilder = inject(UntypedFormBuilder);
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
@Input() CurrentProductsTemplate: any;
/** The Settings tab's allowOverdraft control. */
@Input() allowOverdraft: AbstractControl;
CurrentProductTermsForm: UntypedFormGroup;
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 }> = [
{ value: true, label: 'Yes' },
{ value: false, label: 'No' }
@@ -41,29 +54,59 @@ export class CurrentProductTermsStepComponent implements OnInit {
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() {
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);
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({
interestPostingPeriodType: this.CurrentProductsTemplate.interestPostingPeriodType?.id || '',
autoPost: isEditing ? (this.CurrentProductsTemplate.autoPost ?? true) : true,
frequencyFixDay: isEditing ? (this.CurrentProductsTemplate.frequencyFixDay ?? 30) : 30,
lastInterestDate: this.parseDate(this.CurrentProductsTemplate.lastInterestDate)
interestPostingPeriodType: storedPostingOffered ? storedPosting : defaultPosting,
interestCalculationDaysInYearType: storedDaysInYear === 360 ? 360 : 365,
nominalAnnualInterestRateOverdraft: isEditing ? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft : '',
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 {
if (!dateValue) return null;
if (Array.isArray(dateValue)) {
return new Date(dateValue[0], dateValue[1] - 1, dateValue[2]);
}
if (dateValue instanceof Date) {
return dateValue;
}
return new Date(dateValue);
private applyOverdraftValidators(allowed: boolean) {
const rate = this.CurrentProductTermsForm.get('nominalAnnualInterestRateOverdraft');
rate.setValidators(
allowed
? [
Validators.required,
Validators.min(0.01),
Validators.max(50)
]
: [Validators.min(0)]
);
rate.updateValueAndValidity({ emitEvent: false });
}
private setYesNoOptions(options: any[]) {
@@ -90,23 +133,25 @@ export class CurrentProductTermsStepComponent implements OnInit {
'',
Validators.required
],
autoPost: [true],
frequencyFixDay: [
30,
interestCalculationDaysInYearType: [365],
nominalAnnualInterestRateOverdraft: [
'',
[Validators.min(0)]
],
lastInterestDate: ['']
minOverdraftForInterestCalculation: [
'',
[Validators.min(0)]
],
autoPost: [true]
});
}
get CurrentProductTerms() {
const formValue = { ...this.CurrentProductTermsForm.value };
const dateFormat = this.settingsService.dateFormat;
if (formValue.lastInterestDate instanceof Date) {
formValue.lastInterestDate = this.dateUtils.formatDate(formValue.lastInterestDate, dateFormat) || '';
if (!this.isOverdraftAllowed) {
delete formValue.nominalAnnualInterestRateOverdraft;
delete formValue.minOverdraftForInterestCalculation;
}
return { ...formValue };
return formValue;
}
}
@@ -50,6 +50,7 @@
<div class="cp-tab-content">
<mifosx-current-product-terms-step
[CurrentProductsTemplate]="currentProductAndTemplate"
[allowOverdraft]="settingsForm.get('allowOverdraft')"
></mifosx-current-product-terms-step>
</div>
</mat-tab>
@@ -257,16 +257,10 @@
</span>
</div>
}
@if (currentProduct.frequencyFixDay) {
@if (currentProduct.interestCalculationDaysInYearType?.id) {
<div class="vsp-kv">
<span class="vsp-k">Frequency Fix Day</span>
<span class="vsp-v">{{ currentProduct.frequencyFixDay }}</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>
<span class="vsp-k">Days in Year</span>
<span class="vsp-v">{{ currentProduct.interestCalculationDaysInYearType.id }} Days</span>
</div>
}
</div>
@@ -396,37 +390,57 @@
<span class="vsp-k">{{
'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate
}}</span>
<span class="vsp-v">{{ currentProduct.minOverdraftForInterestCalculation | formatNumber }}</span>
<span class="vsp-v">₹ {{ currentProduct.minOverdraftForInterestCalculation | formatNumber }}</span>
</div>
}
@if (currentProduct.nominalAnnualInterestRateOverdraft) {
<div class="vsp-kv">
<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>
}
@if (currentProduct.overdraftLimit) {
<div class="vsp-kv">
<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>
}
@if (currentProduct.allowOverdraft) {
@if (currentProduct.avgBalanceCalcPeriod) {
@if (currentProduct.odMinLimit) {
<div class="vsp-kv">
<span class="vsp-k">Average Balance Calculation Period</span>
<span class="vsp-v">{{ currentProduct.avgBalanceCalcPeriod }}</span>
<span class="vsp-k">Minimum OD Limit</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>
}
@if (currentProduct.validForPeriod) {
<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>
</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) {
<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>
</div>
}
@@ -588,10 +602,34 @@
<div class="vsp-section-ic vsp-section-ic--green"><fa-icon icon="book"></fa-icon></div>
<h3>{{ 'labels.heading.Accounting' | translate }}</h3>
</div>
<mifosx-view-savings-accounting-details
[accountingRule]="currentProduct.accountingRule"
[accountingMappings]="currentProduct.accountingMappings"
></mifosx-view-savings-accounting-details>
<!--
The current product's own mappings only, under current-account names. The shared savings view is not used:
it labels everything as savings and lists savings-only GLs (Saving reference, Interest on savings...), and
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>
<!-- OTHER GL MAPPINGS -->
@@ -22,7 +22,6 @@ import {
MatRowDef,
MatRow
} 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 { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { YesnoPipe } from '../../../../pipes/yesno.pipe';
@@ -47,7 +46,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
ViewSavingsAccountingDetailsComponent,
DateFormatPipe,
FormatNumberPipe,
YesnoPipe
@@ -102,4 +100,20 @@ export class CurrentProductGeneralTabComponent {
isCashOrAccrualAccounting(): boolean {
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('feesReceivableAccountId', 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('escheatLiabilityId', cashOrAccrual && this.isDormancyTrackingEnabled());
@@ -326,8 +331,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
rows: [
{
key: 'savingsReferenceAccountId',
label: 'Savings Reference Account',
inputLabel: 'Savings reference account',
label: 'Fixed Deposit Reference Account',
inputLabel: 'Fixed deposit reference account',
controlName: 'savingsReferenceAccountId',
options: this.assetAccountOptions,
required: true
@@ -360,8 +365,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
rows: [
{
key: 'savingsControlAccountId',
label: 'Savings Control Account',
inputLabel: 'Savings control account',
label: 'Fixed Deposit Control Account',
inputLabel: 'Fixed deposit control account',
controlName: 'savingsControlAccountId',
options: this.liabilityAccountOptions,
required: true
@@ -375,12 +380,16 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
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',
label: 'Interest Payable Account',
inputLabel: 'Interest payable account',
controlName: 'interestPayableAccountId',
options: this.liabilityAccountOptions,
accrualOnly: true
options: this.liabilityAccountOptions
},
{
key: 'escheatLiabilityId',
@@ -402,8 +411,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
rows: [
{
key: 'interestOnSavingsAccountId',
label: 'Interest On Savings Account',
inputLabel: 'Interest on savings account',
label: 'Interest On Fixed Deposits',
inputLabel: 'Interest on fixed deposits',
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
@@ -481,6 +481,7 @@
<mifosx-view-savings-accounting-details
[accountingRule]="accountingRule"
[accountingMappings]="accountingMappings"
productLabel="Fixed deposit"
></mifosx-view-savings-accounting-details>
}
@@ -74,6 +74,7 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
this.periodFrequencyData = this.fixedDepositProductsTemplate.periodFrequencyTypeOptions || [];
const isEditing = !!this.fixedDepositProductsTemplate.id;
const defaultPeriodTypeId = this.periodFrequencyData[0]?.id ?? null;
const defaultAveragePeriod = this.averagePeriodData[0]?.value || 'Monthly';
const savedEnumTypeId =
this.fixedDepositProductsTemplate.interestCalculationType?.id || this.interestCalculationTypeData?.[0]?.id || 1;
@@ -84,12 +85,16 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
: (this.interestCalculationTypeOptionData?.[0]?.id ?? null);
this.fixedDepositProductTermsForm.patchValue({
// Hidden, unedited-by-anyone fields -- left blank (like their max-/in-multiples-of- siblings
// already correctly are) rather than forced to a real default, or every single product edit
// submitted a "real" value against a product that had none, and a checker's "Proposed changes"
// review showed these two as "changed" on every unrelated edit, never anything a maker touched.
minDepositTerm: this.fixedDepositProductsTemplate.minDepositTerm ?? '',
minDepositTermTypeId: this.fixedDepositProductsTemplate.minDepositTermType?.id ?? '',
// Hidden, unedited-by-anyone fields. On EDIT, left as the product's own saved value with no
// 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 ?? '',
maxDepositTermTypeId: this.fixedDepositProductsTemplate.maxDepositTermType?.id ?? '',
inMultiplesOfDepositTerm: this.fixedDepositProductsTemplate.inMultiplesOfDepositTerm ?? '',
@@ -607,6 +607,7 @@
<mifosx-view-savings-accounting-details
[accountingRule]="fixedDepositProductData.accountingRule"
[accountingMappings]="fixedDepositProductData.accountingMappings"
productLabel="Fixed deposit"
></mifosx-view-savings-accounting-details>
}
</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;
}
}
/* ── 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;
}
@@ -26,16 +26,30 @@ const routes: Routes = [
data: { title: 'AML Rules Master', breadcrumb: 'AML Rules Master' }
},
{
// OSS 1 computed live from the ledger. Lazy — the ExcelJS workbook builder should not weigh
// down the eager bundle.
path: 'rbi/oss1',
// Lazy — the ExcelJS workbook builder should not weigh down the eager bundle.
loadComponent: () => import('./oss1-ral/oss1-ral.component').then((m) => m.Oss1RalComponent),
loadComponent: () => import('./oss-live/oss1-live.component').then((m) => m.Oss1LiveComponent),
data: { title: 'OSS 1 — Return on Assets and Liabilities', breadcrumb: 'OSS 1 (RAL)' }
},
{
path: 'rbi/oss2',
loadComponent: () => import('./oss2-roe/oss2-roe.component').then((m) => m.Oss2RoeComponent),
loadComponent: () => import('./oss-live/oss2-live.component').then((m) => m.Oss2LiveComponent),
data: { title: 'OSS 2 — Return on Earnings', breadcrumb: 'OSS 2 (RoE)' }
},
{
// The filed workbooks as they stood before the returns were computed from the ledger. Kept
// reachable so a figure on a live return can be checked against what was actually filed for
// an earlier quarter; they read from static data and do not move.
path: 'rbi/oss1-filed',
loadComponent: () => import('./oss1-ral/oss1-ral.component').then((m) => m.Oss1RalComponent),
data: { title: 'OSS 1 — As filed (static)', breadcrumb: 'OSS 1 (as filed)' }
},
{
path: 'rbi/oss2-filed',
loadComponent: () => import('./oss2-roe/oss2-roe.component').then((m) => m.Oss2RoeComponent),
data: { title: 'OSS 2 — As filed (static)', breadcrumb: 'OSS 2 (as filed)' }
},
{
path: 'rbi/share-capital',
// Lazy for the same reason as the OSS returns — the ExcelJS workbook builder should not
@@ -48,6 +62,18 @@ const routes: Routes = [
path: 'rbi/oss3',
loadComponent: () => import('./oss3-raq/oss3-raq.component').then((m) => m.Oss3RaqComponent),
data: { title: 'OSS 3 — Return on Asset Quality', breadcrumb: 'OSS 3 (RAQ-UCB)' }
},
{
// CRR return, filed twice a month. Lazy like the other returns — it pulls in ExcelJS.
path: 'rbi/form1',
loadComponent: () => import('./reserve-return/form1-crr.component').then((m) => m.Form1CrrComponent),
data: { title: 'Form 1 — Cash Reserve Ratio', breadcrumb: 'Form 1 (CRR)' }
},
{
// SLR return, filed at the end of each month.
path: 'rbi/form9',
loadComponent: () => import('./reserve-return/form9-slr.component').then((m) => m.Form9SlrComponent),
data: { title: 'Form 9 — Statutory Liquidity Ratio', breadcrumb: 'Form 9 (SLR)' }
}
])
];
@@ -60,4 +60,116 @@ export class RbiCircularsService {
getShareCapitalReturnYears(): Observable<any> {
return this.http.get('/shareproducts/capital-return/financial-years');
}
/**
* Form 1 — CRR, computed live from the ledger for the fortnight the reporting date falls in.
*
* The DTL base and the balances maintained are read as at different dates: the base lags by 45
* days (snapped back to a reporting Friday), the holdings are as at the reporting date itself.
* The response's `basis` block states both, so the lag is auditable rather than implied.
*
* @param reportingDate yyyy-MM-dd. Defaults to the current business date on the backend.
* @param officeId Optional — scopes every figure to one office.
*/
getForm1(reportingDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (reportingDate) {
params = params.set('reportingDate', reportingDate);
}
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
return this.http.get('/rbireservereturn/form1', { params });
}
/**
* Form 9 — SLR, computed live on the month-end position. Any date within a month resolves to that
* month's end, since the return reports the position at close.
*/
getForm9(reportingDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (reportingDate) {
params = params.set('reportingDate', reportingDate);
}
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
return this.http.get('/rbireservereturn/form9', { params });
}
/**
* OSS 1 — Return on Assets and Liabilities, computed live from the ledger.
*
* Every figure is a closing balance as at the quarter end; the bank's own particulars come from
* the institution profile. Any date inside a quarter resolves to that quarter, so a caller never
* has to know which day a quarter ends on.
*
* @param quarterEndDate yyyy-MM-dd — any date in the quarter. Defaults to the business date.
* @param officeId Optional — scopes every figure to one office.
*/
getOss1(quarterEndDate?: string, officeId?: number): Observable<any> {
return this.http.get('/ossreturn/oss1', { params: this.ossParams(quarterEndDate, officeId) });
}
/**
* OSS 2 — Return on Earnings, computed live from the ledger.
*
* Earnings lines report the movement over the quarter rather than a closing balance, so the same
* accounts read as balances would carry forward every earlier quarter's income as well.
*/
getOss2(quarterEndDate?: string, officeId?: number): Observable<any> {
return this.http.get('/ossreturn/oss2', { params: this.ossParams(quarterEndDate, officeId) });
}
// ── Saved returns ──
/**
* Returns saved earlier, newest first. A saved return is served from the snapshot taken when it
* was saved, never recomputed — a backdated entry or a changed mapping would otherwise produce a
* different statement from the one that was reviewed.
*/
getSavedReturns(formCode?: string): Observable<any> {
let params = new HttpParams();
if (formCode) {
params = params.set('formCode', formCode);
}
return this.http.get('/rbiformafiling', { params });
}
/** One saved return, with the stored snapshot, so the screen renders what was saved. */
getSavedReturn(filingId: number): Observable<any> {
return this.http.get(`/rbiformafiling/${filingId}`);
}
/** Keeps the return currently on screen. Re-saving the same form and date replaces it. */
saveReturn(formCode: string, reportingDate: string, report: any, remarks?: string): Observable<any> {
return this.http.post('/rbiformafiling', {
formCode,
reportingDate,
report,
remarks,
locale: 'en',
dateFormat: 'yyyy-MM-dd'
});
}
/** Moves a saved return along its filing lifecycle; the snapshot itself is untouched. */
updateSavedReturnStatus(filingId: number, status: string, remarks?: string): Observable<any> {
return this.http.post(`/rbiformafiling/${filingId}/status`, { status, remarks });
}
deleteSavedReturn(filingId: number): Observable<any> {
return this.http.delete(`/rbiformafiling/${filingId}`);
}
private ossParams(quarterEndDate?: string, officeId?: number): HttpParams {
let params = new HttpParams();
if (quarterEndDate) {
params = params.set('quarterEndDate', quarterEndDate);
}
if (officeId != null) {
params = params.set('officeId', String(officeId));
}
return params;
}
}
@@ -0,0 +1,43 @@
/**
* 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 { Observable } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ReserveReturnBase } from './reserve-return.base';
/**
* RBI Form 1 — Cash Reserve Ratio, filed twice a month.
*
* The return compares the CRR required on an earlier fortnight's Demand and Time Liabilities
* against the cash and bank balances held today. The two sides are deliberately read on different
* dates — see the basis panel on the screen, which states both.
*/
@Component({
selector: 'mifosx-form1-crr',
templateUrl: './reserve-return.component.html',
styleUrls: ['./reserve-return.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class Form1CrrComponent extends ReserveReturnBase implements OnInit {
protected readonly exportStem = 'RBI_Form1_CRR';
protected readonly formCode = 'FORM_1';
protected fetch(reportingDate: string): Observable<any> {
return this.rbiService.getForm1(reportingDate);
}
ngOnInit(): void {
this.load();
this.loadSaved();
}
}
@@ -0,0 +1,43 @@
/**
* 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 { Observable } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ReserveReturnBase } from './reserve-return.base';
/**
* RBI Form 9 — Statutory Liquidity Ratio, filed at the end of the month.
*
* Unlike Form 1 there is no lag: both the liability base and the SLR-eligible investments are read
* as at month end, so the return states the position actually held at close. Any date the user
* picks resolves to that month's end.
*/
@Component({
selector: 'mifosx-form9-slr',
templateUrl: './reserve-return.component.html',
styleUrls: ['./reserve-return.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class Form9SlrComponent extends ReserveReturnBase implements OnInit {
protected readonly exportStem = 'RBI_Form9_SLR';
protected readonly formCode = 'FORM_9';
protected fetch(reportingDate: string): Observable<any> {
return this.rbiService.getForm9(reportingDate);
}
ngOnInit(): void {
this.load();
this.loadSaved();
}
}
@@ -0,0 +1,696 @@
/**
* 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 configured figure on the return, with the accounts it was derived from. */
export interface ReserveLine {
label: string;
configKey: string;
configured: boolean;
asOnDate: string;
amount: number | null;
/**
* The accounts the figure was derived from. `balance` is null and `excluded` set when an account
* is still pinned to the role but no longer countable — disabled, or turned into a header whose
* balance would double-count its children.
*/
accounts: ReserveLineAccount[];
}
/**
* One account behind a line. `accountBalance` is what the ledger holds; `balance` is the portion
* this head takes of it. The two differ whenever `weightPercentage` is under 100 — RBI treats part
* of a savings balance as a demand liability and the rest as time, so the same account can appear
* twice under one head with a different share each time.
*/
export interface ReserveLineAccount {
glAccountId: number;
glCode: string;
name: string;
/** The account's own balance, before the head's share is applied. */
accountBalance?: number | null;
/** The share of that balance this head takes. 100 means the whole of it. */
weightPercentage?: number | null;
/** DEMAND or TIME on a liability head; null elsewhere. */
liabilityClass?: string | null;
balance: number | null;
excluded?: 'disabled' | 'header';
}
/** One row of the Memorandum or an Annexure — a stated figure, entering no total. */
export interface SectionRow {
label: string;
configKey: string;
configured: boolean;
amount: number | null;
accounts: { glAccountId: number; glCode: string; name: string; balance: number }[];
}
/**
* Compliance across the whole fortnight.
*
* CRR is not met by the closing day's balance: the full requirement must be held on average over the
* fortnight AND a daily minimum on every single day of it, so a bank that ran empty for thirteen days
* and topped up on the last one fails on the daily test while passing on the closing position.
*/
export interface FortnightMaintenance {
/** Each day of the month, with the requirement it was measured against and the gap. */
days: { date: string; required: number; amount: number; surplus: number }[];
/** The month the table covers. */
periodStart?: string;
periodEnd?: string;
periodLabel?: string;
dayCount: number;
average: number;
lowest: number;
lowestOn: string | null;
/** False while the fortnight is still running, so the average is of the days elapsed. */
fortnightComplete: boolean;
requiredAverage: number;
dailyMinimumPercent: number;
dailyMinimumAmount: number;
averageMet: boolean;
dailyMinimumMet: boolean;
averageShortfall: number;
}
/** The whole return, as the backend computes it. */
export interface ReserveReturn {
formCode: string;
formName: string;
frequency: string;
reportingDate: string;
rateType: string;
ratePercentage: number | null;
/** True while the rate is still the placeholder shipped with the installation. */
rateIsSeedDefault: boolean;
basis: {
lagDays?: number;
rawLagDate?: string;
/** The nominal reporting Friday, before any shift off a holiday. */
reportingFriday?: string;
dtlAsOnDate: string;
holdingsAsOnDate: string;
/** True when that Friday was a holiday and the position was taken a working day earlier. */
shiftedForHoliday?: boolean;
note: string;
};
lines: ReserveLine[];
/** Form A's Memorandum and its two Annexures — sections of the return, not lines on it. */
memorandum?: SectionRow[];
annexureA?: SectionRow[];
annexureB?: SectionRow[];
/** Compliance across the fortnight rather than on the reporting date alone. Form 1 only. */
maintenance?: FortnightMaintenance;
computable: boolean;
dtlAmount?: number;
requiredAmount?: number;
maintainedAmount?: number;
surplusOrShortfall?: number;
maintainedPercentage?: number | null;
compliant?: boolean;
unmetRequirements: string[];
// Form 1 only.
fortnight?: number;
fortnightLabel?: string;
fortnightStart?: string;
fortnightEnd?: string;
// Form 9 only.
requestedDate?: string;
monthStart?: string;
}
/**
* Shared behaviour for the two reserve returns — Form 1 (CRR) and Form 9 (SLR).
*
* Both are the same statement: a percentage of Demand and Time Liabilities must be maintained in a
* defined class of assets, and the return compares what is required against what is held. They
* differ only in which figures make up the maintained side and on which dates each side is read,
* so everything except the fetch and the Excel layout lives here.
*
* Nothing is computed in the browser. The figures come from the ledger through one endpoint that
* already aggregates them; re-deriving totals here would produce a second set of numbers that could
* disagree with the filed return.
*/
export abstract class ReserveReturnBase {
protected rbiService = inject(RbiCircularsService);
/** Which return to fetch. */
protected abstract fetch(reportingDate: string): Observable<any>;
/** Filename stem for the Excel export. */
protected abstract readonly exportStem: string;
/** Which return this screen is, so saved returns list only this one's. */
protected abstract readonly formCode: string;
data: ReserveReturn | null = null;
loading = true;
errorMsg = '';
generating = false;
/** The reporting date the user is looking at. Editable, so past periods can be reviewed. */
reportingDate = new Date().toISOString().slice(0, 10);
toastMsg = '';
toastVisible = false;
private toastTimer: any;
/** Returns saved earlier, listed under the live one. */
savedReturns: any[] = [];
savedLoading = false;
saving = false;
savedRemarks = '';
/**
* Which half of the screen is showing: the return itself, or the ones kept on record.
*
* Two tabs rather than one long page: the live return and the saved list are read at different
* times and for different reasons, and stacking them meant scrolling past the whole statement -
* including a month of daily rows - to reach the list at all.
*/
activeTab: 'report' | 'saved' = 'report';
showTab(tab: 'report' | 'saved'): void {
this.activeTab = tab;
}
/** The saved return currently open below the list, or null when none is. */
viewingSaved: any = null;
/**
* The opened snapshot's own statement, rendered in its own section. Held separately from `data` so
* the live return stays on screen alongside it and the two can be compared directly.
*/
savedData: ReserveReturn | null = null;
load(): void {
this.loading = true;
this.errorMsg = '';
this.fetch(this.reportingDate)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute this return from the ledger.';
return of(null);
})
)
.subscribe((res: ReserveReturn | null) => {
this.loading = false;
this.data = res;
});
}
onDateChange(): void {
this.load();
}
/** Jump to the period containing today. */
resetToToday(): void {
this.reportingDate = new Date().toISOString().slice(0, 10);
this.load();
}
// ── presentation helpers ──
/**
* The titled sections Form A carries alongside the main statement, in the order the format states
* them. Each is skipped by the template when it has no rows, so Form 9 — which has none — is
* unaffected.
*/
get namedSections(): { title: string; hint: string; rows: SectionRow[] }[] {
if (!this.data) {
return [];
}
return [
{
title: 'Memorandum to Form A',
hint: 'Stated alongside the return. These figures are reported, not added into the CRR requirement.',
rows: this.data.memorandum ?? []
},
{
title: 'Annexure A — Foreign currency liabilities and assets',
hint: 'Reported in INR equivalent at the applicable reference rate. Nil where the bank has no foreign-currency business.',
rows: this.data.annexureA ?? []
},
{
title: 'Annexure B — Investment details',
hint: 'Investments split between approved and non-approved securities, shares, debentures and bonds.',
rows: this.data.annexureB ?? []
}
];
}
get isShortfall(): boolean {
return !!this.data?.computable && this.data.compliant === false;
}
/** Magnitude of the gap, so the sign lives in the label rather than in the number. */
get shortfallAmount(): number {
return Math.abs(this.data?.surplusOrShortfall ?? 0);
}
// ── Saved returns ──
loadSaved(): void {
this.savedLoading = true;
this.rbiService
.getSavedReturns(this.formCode)
.pipe(catchError(() => of([])))
.subscribe((rows: any[]) => {
this.savedLoading = false;
this.savedReturns = rows || [];
});
}
/** Keeps the return on screen, so it can be read again later exactly as it stands now. */
saveCurrent(): void {
if (!this.data) {
return;
}
this.saving = true;
this.rbiService
.saveReturn(this.data.formCode, this.data.reportingDate, this.data, this.savedRemarks || undefined)
.pipe(
catchError((err: any) => {
this.saving = false;
this.toast(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this return.');
return of(null);
})
)
.subscribe((rows: any[] | null) => {
this.saving = false;
if (!rows) {
return;
}
this.savedReturns = rows;
this.savedRemarks = '';
this.toast('Return saved.');
});
}
/**
* Opens a saved return in its own section below the list, leaving the live return on screen.
*
* Deliberately not in place of the live one: the reason to open a saved return is almost always to
* compare it against where the bank stands now, and swapping the live figures out makes that
* comparison impossible without clicking back and forth from memory.
*/
viewSaved(row: any): void {
// Clicking the open one closes it, so the button toggles rather than re-fetching.
if (this.viewingSaved?.id === row.id) {
this.closeSaved();
return;
}
this.rbiService
.getSavedReturn(row.id)
.pipe(
catchError(() => {
this.toast('Could not open this saved return.');
return of(null);
})
)
.subscribe((saved: any) => {
if (!saved?.report) {
this.toast('This saved return has no stored statement.');
return;
}
this.viewingSaved = saved;
this.savedData = saved.report as ReserveReturn;
// Opens on the Saved tab, where the list it was chosen from is - never jumping the user to
// the live return, which is a different statement.
this.activeTab = 'saved';
setTimeout(() => document.getElementById('rr-saved-view')?.scrollIntoView({ behavior: 'smooth' }), 0);
});
}
/** Closes the opened snapshot. The live return was never hidden, so nothing is restored. */
closeSaved(): void {
this.viewingSaved = null;
this.savedData = null;
}
/** Kept for callers that still name the old action. */
backToLive(): void {
this.closeSaved();
}
/** Magnitude of the opened snapshot's gap, so the sign lives in its label rather than the number. */
get savedSurplus(): number {
return Math.abs(this.savedData?.surplusOrShortfall ?? 0);
}
updateSavedStatus(row: any, status: string): void {
this.rbiService
.updateSavedReturnStatus(row.id, status)
.pipe(
catchError((err: any) => {
this.toast(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not update the status.');
return of(null);
})
)
.subscribe((rows: any[] | null) => {
if (!rows) {
return;
}
this.savedReturns = rows;
if (this.viewingSaved?.id === row.id) {
this.viewingSaved = { ...this.viewingSaved, status };
}
this.toast(`Marked ${this.statusLabel(status)}.`);
});
}
deleteSaved(row: any): void {
this.rbiService
.deleteSavedReturn(row.id)
.pipe(
catchError(() => {
this.toast('Could not remove this saved return.');
return of(null);
})
)
.subscribe((rows: any[] | null) => {
if (!rows) {
return;
}
this.savedReturns = rows;
if (this.viewingSaved?.id === row.id) {
this.backToLive();
}
this.toast('Saved return removed.');
});
}
/** The stages a return moves through, in order, for the status menu. */
readonly filingStatuses = [
'DRAFT',
'GENERATED',
'CHECKED',
'APPROVED',
'PROVISIONAL_SUBMITTED',
'FINAL_SUBMITTED',
'REVISED'
];
statusLabel(status: string): string {
const labels: Record<string, string> = {
DRAFT: 'Draft',
GENERATED: 'Generated',
CHECKED: 'Checked',
APPROVED: 'Approved',
PROVISIONAL_SUBMITTED: 'Provisional submitted',
FINAL_SUBMITTED: 'Final submitted',
REVISED: 'Revised'
};
return labels[status] ?? status;
}
/** Hands the rendered return to the browser's print dialog, which also prints to PDF. */
printReturn(): void {
window.print();
}
/**
* True when a head takes only part of an account, so the printed figure is not that account's
* balance. Anything at or above 100, or unset, is the whole balance and needs no explanation.
*/
isPartial(account: ReserveLineAccount): boolean {
const weight = account.weightPercentage;
return weight !== null && weight !== undefined && weight < 100 && account.accountBalance != null;
}
amount(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
percent(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
return `${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}%`;
}
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 filed: the same figures the screen shows, plus the basis dates 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();
const sheet = wb.addWorksheet(this.data.formCode.replace('_', ' '));
// Five columns because the daily table is the widest block on the sheet; the statement above
// it uses the first three and leaves the rest empty.
sheet.columns = [
{ width: 52 },
{ width: 22 },
{ width: 20 },
{ width: 22 },
{ width: 16 }];
addBanner(sheet, `${this.data.formName} — ${this.data.frequency}`, 'C');
let start = sheet.rowCount + 1;
this.addKeyValue(sheet, 'Reporting date', this.data.reportingDate);
if (this.data.fortnightLabel) {
this.addKeyValue(
sheet,
'Fortnight',
`${this.data.fortnightLabel} (${this.data.fortnightStart} to ${this.data.fortnightEnd})`
);
}
this.addKeyValue(sheet, 'DTL base as at', this.data.basis.dtlAsOnDate);
this.addKeyValue(sheet, 'Balances maintained as at', this.data.basis.holdingsAsOnDate);
if (this.data.basis.rawLagDate) {
this.addKeyValue(sheet, `Lag (${this.data.basis.lagDays} days) falls on`, this.data.basis.rawLagDate);
}
this.addKeyValue(
sheet,
`${this.data.rateType} rate applied`,
this.data.ratePercentage === null
? 'Not set'
: `${this.data.ratePercentage}%${this.data.rateIsSeedDefault ? ' (placeholder — not confirmed)' : ''}`
);
boxAllCells(sheet, start, sheet.rowCount, 2);
sheet.addRow([]);
// Basis note, spelled out so the export is self-explanatory away from the screen.
const noteRow = sheet.addRow([this.data.basis.note]);
sheet.mergeCells(noteRow.number, 1, noteRow.number, 3);
noteRow.alignment = { wrapText: true };
noteRow.font = { size: 9, italic: true };
sheet.addRow([]);
// Figures.
start = sheet.rowCount + 1;
const head = sheet.addRow([
'Particulars',
'As at',
'Amount (INR)'
]);
head.font = { bold: true };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
for (const line of this.data.lines) {
const row = sheet.addRow([
line.label,
line.asOnDate,
line.configured ? line.amount : 'Not configured'
]);
row.getCell(1).alignment = { wrapText: true };
row.getCell(3).numFmt = '#,##0.00';
row.getCell(3).alignment = { horizontal: 'right' };
// Account-level breakdown, indented under its line. An excluded account is named with its
// reason rather than dropped, so the workbook cannot read as complete when the figure is
// short by it — the file is what gets filed, so the caveat has to travel in it.
for (const account of line.accounts) {
// A partial share is named on the row too: without it the figure reads as the account's
// own balance and cannot be reconciled back to the ledger.
const portion = this.isPartial(account)
? ` (${account.weightPercentage}% of ${this.amount(account.accountBalance)})`
: '';
const sub = sheet.addRow([
` ${account.glCode} — ${account.name}${portion}${account.excluded ? ` (excluded — ${account.excluded})` : ''}`,
account.liabilityClass ?? '',
account.excluded ? 'Excluded' : account.balance
]);
sub.font = { size: 9, color: { argb: account.excluded ? 'FFB06000' : 'FF666666' } };
sub.getCell(3).numFmt = '#,##0.00';
sub.getCell(3).alignment = { horizontal: 'right' };
}
}
if (this.data.computable) {
const required = sheet.addRow([
`${this.data.rateType} required (${this.data.ratePercentage}% of DTL)`,
'',
this.data.requiredAmount ?? null
]);
const maintained = sheet.addRow([
`${this.data.rateType} maintained`,
'',
this.data.maintainedAmount ?? null
]);
const gap = sheet.addRow([
this.data.compliant ? 'Surplus' : 'Shortfall',
'',
this.data.surplusOrShortfall ?? null
]);
for (const row of [
required,
maintained,
gap
]) {
row.font = { bold: true };
row.getCell(3).numFmt = '#,##0.00';
row.getCell(3).alignment = { horizontal: 'right' };
row.getCell(3).fill = solidFill(XL.derivedBlue);
}
}
boxAllCells(sheet, start, sheet.rowCount, 3);
// Fortnight maintenance, which is what CRR compliance actually turns on. A workbook showing
// only the closing position would let a fortnight that breached the daily floor look clean.
if (this.data.maintenance) {
const m = this.data.maintenance;
sheet.addRow([]);
const head = sheet.addRow([
'Maintenance over the fortnight',
'',
''
]);
head.font = { bold: true };
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
const start = sheet.rowCount + 1;
this.addKeyValue(sheet, 'Average maintained', this.amount(m.average));
this.addKeyValue(sheet, 'Required average', this.amount(m.requiredAverage));
this.addKeyValue(sheet, 'Average test', m.averageMet ? 'Met' : 'NOT MET');
this.addKeyValue(
sheet,
`Daily floor (${m.dailyMinimumPercent}% of requirement)`,
this.amount(m.dailyMinimumAmount)
);
this.addKeyValue(sheet, 'Lowest day', `${this.amount(m.lowest)} on ${m.lowestOn}`);
this.addKeyValue(sheet, 'Daily minimum test', m.dailyMinimumMet ? 'Met' : 'NOT MET');
this.addKeyValue(
sheet,
'Days counted',
`${m.dayCount}${m.fortnightComplete ? ' (full fortnight)' : ' (to reporting date)'}`
);
boxAllCells(sheet, start, sheet.rowCount, 2);
sheet.addRow([]);
const dayHead = sheet.addRow([
'Date',
'Required (INR)',
'Actual (INR)',
'Surplus / (Shortfall)',
'Against floor'
]);
dayHead.font = { bold: true };
dayHead.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
const dayStart = sheet.rowCount;
for (const day of m.days) {
const row = sheet.addRow([
day.date,
day.required,
day.amount,
day.surplus,
day.amount < m.dailyMinimumAmount ? 'Below floor' : 'OK'
]);
for (const col of [
2,
3,
4
]) {
row.getCell(col).numFmt = '#,##0.00';
row.getCell(col).alignment = { horizontal: 'right' };
}
}
boxAllCells(sheet, dayStart, sheet.rowCount, 5);
}
// The Memorandum and Annexures, each under its own heading.
for (const section of this.namedSections) {
if (!section.rows.length) {
continue;
}
sheet.addRow([]);
const title = sheet.addRow([
section.title,
'',
''
]);
title.font = { bold: true };
title.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
const start = sheet.rowCount;
for (const row of section.rows) {
const excelRow = sheet.addRow([
row.label,
'',
row.configured ? row.amount : 'Not configured'
]);
excelRow.getCell(1).alignment = { wrapText: true };
excelRow.getCell(3).numFmt = '#,##0.00';
excelRow.getCell(3).alignment = { horizontal: 'right' };
}
boxAllCells(sheet, start, sheet.rowCount, 3);
}
// Caveats travel with the workbook, not only the screen: a return computed on the placeholder
// rate is arithmetically right and still must not be filed, and the file is what gets sent on.
if (this.data.unmetRequirements.length) {
sheet.addRow([]);
const prefix = this.data.computable ? 'Check before filing' : 'Not computed';
for (const reason of this.data.unmetRequirements) {
const row = sheet.addRow([`${prefix}: ${reason}`]);
sheet.mergeCells(row.number, 1, row.number, 3);
row.font = { bold: true, size: 9 };
row.alignment = { wrapText: true };
}
}
await downloadWorkbook(wb, `${this.exportStem}_${this.data.reportingDate}.xlsx`);
this.toast('Workbook downloaded.');
} finally {
this.generating = false;
}
}
private addKeyValue(sheet: ExcelJS.Worksheet, label: string, value: string): void {
const row = sheet.addRow([
label,
value
]);
row.getCell(1).font = { bold: true };
row.getCell(1).fill = solidFill(XL.keyCell);
}
}
@@ -0,0 +1,558 @@
<!--
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="rr-container">
@if (loading) {
<div class="rr-loading">Computing from the ledger…</div>
} @else if (errorMsg) {
<div class="rr-alert rr-alert--error">{{ errorMsg }}</div>
} @else if (data) {
<!-- ═══ Header ═══ -->
<div class="rr-header">
<div class="rr-header__text">
<h2 class="rr-header__title">{{ data.formName }}</h2>
<span class="rr-header__freq">{{ data.frequency }}</span>
@if (data.fortnightLabel) {
<span class="rr-header__period">
{{ data.fortnightLabel }} · {{ data.fortnightStart }} to {{ data.fortnightEnd }}
</span>
} @else {
<span class="rr-header__period">Month ended {{ data.reportingDate }}</span>
}
</div>
<div class="rr-header__actions">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rr-date">
<mat-label>Reporting date</mat-label>
<input matInput type="date" [(ngModel)]="reportingDate" (change)="onDateChange()" />
</mat-form-field>
<button mat-stroked-button (click)="resetToToday()">Today</button>
<button mat-stroked-button (click)="printReturn()">
<i class="fas fa-print"></i>
Print
</button>
<button mat-stroked-button [disabled]="saving || !data.computable" (click)="saveCurrent()">
<i class="fas fa-bookmark"></i>
{{ saving ? 'Saving…' : 'Save' }}
</button>
<button mat-raised-button color="primary" [disabled]="generating" (click)="exportWorkbook()">
<i class="fas fa-file-excel"></i>
{{ generating ? 'Generating…' : 'Export' }}
</button>
</div>
</div>
<!--
═══ Tabs ═══
The return and the saved list are read at different times and for different reasons, so they
are two halves of the screen rather than one page where reaching the list means scrolling past
a whole statement and a month of daily rows.
-->
<div class="rr-tabs rr-noprint">
<button class="rr-tab" [class.rr-tab--on]="activeTab === 'report'" (click)="showTab('report')">
<i class="fas fa-file-invoice"></i> Report
</button>
<button class="rr-tab" [class.rr-tab--on]="activeTab === 'saved'" (click)="showTab('saved')">
<i class="fas fa-bookmark"></i> Saved returns
@if (savedReturns.length) {
<span class="rr-tab__count">{{ savedReturns.length }}</span>
}
</button>
</div>
@if (activeTab === 'report') {
<!--
Caveats show whenever there are any — not only when the return cannot compute. A return
computed on the placeholder rate is arithmetically right and still must not be filed as-is,
so hiding that behind the not-computable branch would bury the one thing worth saying.
-->
@if (data.unmetRequirements.length) {
<div class="rr-alert rr-alert--warn">
<strong>
{{ data.computable ? 'Check this before filing.' : 'This return cannot be computed yet.' }}
</strong>
<ul class="rr-unmet">
@for (reason of data.unmetRequirements; track reason) {
<li>{{ reason }}</li>
}
</ul>
<a class="rr-unmet__link" [routerLink]="['/organization/rbi-reserve-config']">
Open RBI Reserve Configuration →
</a>
</div>
}
<!-- ═══ Basis ═══ -->
<div class="rr-basis">
<div class="rr-basis__row">
<div class="rr-basis__item">
<span class="rr-basis__label">DTL base as at</span>
<span class="rr-basis__value">{{ data.basis.dtlAsOnDate }}</span>
</div>
<div class="rr-basis__arrow"><i class="fas fa-arrow-right"></i></div>
<div class="rr-basis__item">
<span class="rr-basis__label">Balances maintained as at</span>
<span class="rr-basis__value">{{ data.basis.holdingsAsOnDate }}</span>
</div>
@if (data.basis.rawLagDate) {
<div class="rr-basis__item rr-basis__item--muted">
<span class="rr-basis__label">{{ data.basis.lagDays }}-day lag falls on</span>
<span class="rr-basis__value">{{ data.basis.rawLagDate }}</span>
</div>
}
</div>
<p class="rr-basis__note">{{ data.basis.note }}</p>
</div>
<!-- ═══ Summary ═══ -->
@if (data.computable) {
<div class="rr-summary">
<!-- NDTL, not DTL: the rate is charged on the net figure, so the base shown must be the one
the required amount was actually computed from. DTL and what was netted off it are
given underneath, so the subtraction is visible rather than implied. -->
<div class="rr-stat">
<span class="rr-stat__label">NDTL base</span>
<span class="rr-stat__value">{{ amount(data.ndtlAmount) }}</span>
<span class="rr-stat__sub">
DTL {{ amount(data.dtlAmount) }} less {{ amount(data.interBankAssets) }} · as at
{{ data.basis.dtlAsOnDate }}
</span>
</div>
<div class="rr-stat" [class.rr-stat--provisional]="data.rateIsSeedDefault">
<span class="rr-stat__label">{{ data.rateType }} rate</span>
<span class="rr-stat__value">{{ data.ratePercentage }}%</span>
@if (data.rateIsSeedDefault) {
<span class="rr-stat__sub rr-stat__sub--warn">Placeholder — not confirmed</span>
} @else {
<span class="rr-stat__sub">in force on {{ data.reportingDate }}</span>
}
</div>
<div class="rr-stat">
<span class="rr-stat__label">Required</span>
<span class="rr-stat__value">{{ amount(data.requiredAmount) }}</span>
<span class="rr-stat__sub">{{ data.ratePercentage }}% of NDTL</span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">Maintained</span>
<span class="rr-stat__value">{{ amount(data.maintainedAmount) }}</span>
<span class="rr-stat__sub">{{ percent(data.maintainedPercentage) }} of NDTL</span>
</div>
<div class="rr-stat" [class.rr-stat--bad]="isShortfall" [class.rr-stat--good]="!isShortfall">
<span class="rr-stat__label">{{ isShortfall ? 'Shortfall' : 'Surplus' }}</span>
<span class="rr-stat__value">{{ amount(shortfallAmount) }}</span>
<span class="rr-stat__sub">{{ isShortfall ? 'Below requirement' : 'Above requirement' }}</span>
</div>
</div>
}
<!-- ═══ Lines ═══ -->
<div class="rr-card">
<table class="rr-table">
<thead>
<tr>
<th>Particulars</th>
<th>As at</th>
<th class="rr-table__num">Amount (INR)</th>
</tr>
</thead>
<tbody>
<!-- Tracked by position, not by configKey: derived rows (the demand/time split, NDTL)
have no config key of their own, so keying on it would collide them into one. -->
@for (line of data.lines; track $index) {
<tr
class="rr-line"
[class.rr-line--unconfigured]="!line.configured"
[class.rr-line--derived]="line.derived"
>
<td>
<span class="rr-line__label">{{ line.label }}</span>
@if (!line.configured) {
<span class="rr-badge rr-badge--unmapped">Not configured</span>
}
</td>
<td class="rr-line__date">{{ line.asOnDate }}</td>
<td class="rr-table__num">
@if (line.configured) {
{{ amount(line.amount) }}
} @else {
<span class="rr-dash">—</span>
}
</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="rr-subline" [class.rr-subline--excluded]="account.excluded">
<td>
<span class="rr-subline__code">{{ account.glCode }}</span>
{{ account.name }}
<!-- An account still pinned to the role but out of the total: named here so the
line's figure can be read as short by it, rather than looking complete. -->
@if (account.excluded) {
<span class="rr-subline__excluded">excluded — {{ account.excluded }}</span>
}
<!-- Where only part of an account is taken, the figure below is not the account's
balance and must not be read as one: state the whole balance and the share of
it this head takes, so the two can be reconciled without opening the ledger. -->
@if (isPartial(account)) {
<span class="rr-subline__portion">
{{ account.weightPercentage }}% of {{ amount(account.accountBalance) }}
</span>
}
<!-- Which head the portion falls under, on a liability line that carries both. -->
@if (account.liabilityClass) {
<span class="rr-subline__class">{{ account.liabilityClass | titlecase }}</span>
}
</td>
<td></td>
<td class="rr-table__num">{{ amount(account.balance) }}</td>
</tr>
}
}
@if (data.computable) {
<tr class="rr-total">
<td>{{ data.rateType }} required ({{ data.ratePercentage }}% of NDTL)</td>
<td></td>
<td class="rr-table__num">{{ amount(data.requiredAmount) }}</td>
</tr>
<tr class="rr-total">
<td>{{ data.rateType }} maintained</td>
<td></td>
<td class="rr-table__num">{{ amount(data.maintainedAmount) }}</td>
</tr>
<tr class="rr-total" [class.rr-total--bad]="isShortfall">
<td>{{ isShortfall ? 'Shortfall' : 'Surplus' }}</td>
<td></td>
<td class="rr-table__num">{{ amount(shortfallAmount) }}</td>
</tr>
}
</tbody>
</table>
</div>
<!--
═══ Fortnight maintenance ═══
CRR is not met by the closing day's balance: the requirement must be held on average across
the fortnight AND a daily minimum on every day of it. A bank can pass the closing position and
still fail, so both tests are stated.
-->
@if (data.maintenance; as m) {
<div class="rr-card rr-card--section">
<h3 class="rr-section__title">
Daily maintenance — {{ m.periodLabel || 'this month' }}
@if (!m.fortnightComplete) {
<span class="rr-badge rr-badge--partial">In progress — {{ m.dayCount }} days so far</span>
}
</h3>
<p class="rr-section__hint">
Every day of the month, against the requirement in force. CRR is reckoned over the period rather than on one
day: the full requirement must be held on average, and at least
{{ m.dailyMinimumPercent }}% of it on every single day.
</p>
<div class="rr-summary">
<div class="rr-stat" [class.rr-stat--good]="m.averageMet" [class.rr-stat--bad]="!m.averageMet">
<span class="rr-stat__label">Average maintained</span>
<span class="rr-stat__value">{{ amount(m.average) }}</span>
<span class="rr-stat__sub">
{{ m.averageMet ? 'Meets' : 'Below' }} required {{ amount(m.requiredAverage) }}
</span>
</div>
<div class="rr-stat" [class.rr-stat--good]="m.dailyMinimumMet" [class.rr-stat--bad]="!m.dailyMinimumMet">
<span class="rr-stat__label">Lowest day</span>
<span class="rr-stat__value">{{ amount(m.lowest) }}</span>
<span class="rr-stat__sub"> on {{ m.lowestOn }} · floor {{ amount(m.dailyMinimumAmount) }} </span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">Days counted</span>
<span class="rr-stat__value">{{ m.dayCount }}</span>
<span class="rr-stat__sub">{{ m.fortnightComplete ? 'Full fortnight' : 'To reporting date' }}</span>
</div>
</div>
<table class="rr-table">
<thead>
<tr>
<th>Date</th>
<th class="rr-table__num">Required (INR)</th>
<th class="rr-table__num">Actual (INR)</th>
<th class="rr-table__num">Surplus / (Shortfall)</th>
<th class="rr-table__num">Against floor</th>
</tr>
</thead>
<tbody>
@for (day of m.days; track day.date) {
<tr [class.rr-day--short]="day.amount < m.dailyMinimumAmount">
<td>{{ day.date }}</td>
<td class="rr-table__num">{{ amount(day.required) }}</td>
<td class="rr-table__num">{{ amount(day.amount) }}</td>
<td class="rr-table__num" [class.rr-neg]="day.surplus < 0">{{ amount(day.surplus) }}</td>
<td class="rr-table__num">
@if (day.amount < m.dailyMinimumAmount) {
<span class="rr-badge rr-badge--unmapped">Below floor</span>
} @else {
<span class="rr-ok">OK</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ═══ Memorandum and Annexures ═══ -->
@for (section of namedSections; track section.title) {
@if (section.rows.length) {
<div class="rr-card rr-card--section">
<h3 class="rr-section__title">{{ section.title }}</h3>
<p class="rr-section__hint">{{ section.hint }}</p>
<table class="rr-table">
<thead>
<tr>
<th>Particulars</th>
<th class="rr-table__num">Amount (INR)</th>
</tr>
</thead>
<tbody>
@for (row of section.rows; track row.configKey) {
<tr [class.rr-line--unconfigured]="!row.configured">
<td>
{{ row.label }}
@if (!row.configured) {
<span class="rr-badge rr-badge--unmapped">Not configured</span>
}
</td>
<td class="rr-table__num">
@if (row.configured) {
{{ amount(row.amount) }}
} @else {
<span class="rr-dash">—</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
}
}
<!--
═══ Saved returns ═══
Outside the `@if (data)` block so the list stays reachable even when the live return cannot be
computed — a saved return from a month when it could is exactly what is wanted then, and a tab
that vanishes when the live figures fail would hide exactly the returns that still work.
-->
@if (activeTab === 'saved') {
<div class="rr-card rr-card--section rr-noprint">
<h3 class="rr-section__title">
Saved returns
@if (savedReturns.length) {
<span class="rr-badge rr-badge--mapped">{{ savedReturns.length }}</span>
}
</h3>
<p class="rr-section__hint">
Returns kept for the record. Each opens exactly as it was saved — the stored statement, not a re-run of today's
ledger, so a figure already reported cannot change underneath it.
</p>
@if (data?.computable) {
<div class="rr-save-row">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rr-save-row__note">
<mat-label>Note (optional)</mat-label>
<input matInput [(ngModel)]="savedRemarks" placeholder="e.g. provisional filing for this fortnight" />
</mat-form-field>
<button mat-raised-button color="primary" [disabled]="saving" (click)="saveCurrent()">
{{ saving ? 'Saving…' : 'Save this return' }}
</button>
</div>
}
@if (savedLoading) {
<div class="rr-loading">Loading saved returns…</div>
} @else if (!savedReturns.length) {
<div class="rr-empty">Nothing saved yet. Generate a return and press Save to keep it.</div>
} @else {
<table class="rr-table">
<thead>
<tr>
<th>Reporting date</th>
<th>Period</th>
<th class="rr-table__num">NDTL</th>
<th class="rr-table__num">Required</th>
<th class="rr-table__num">Maintained</th>
<th>Status</th>
<th>Saved</th>
<th></th>
</tr>
</thead>
<tbody>
@for (row of savedReturns; track row.id) {
<tr [class.rr-saved--open]="viewingSaved?.id === row.id">
<td>{{ row.reportingDate }}</td>
<td class="rr-saved__period">{{ row.periodStart }} → {{ row.periodEnd }}</td>
<td class="rr-table__num">{{ amount(row.ndtlAmount) }}</td>
<td class="rr-table__num">{{ amount(row.requiredAmount) }}</td>
<td class="rr-table__num">{{ amount(row.maintainedAmount) }}</td>
<td>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rr-saved__status">
<mat-select [value]="row.status" (selectionChange)="updateSavedStatus(row, $event.value)">
@for (s of filingStatuses; track s) {
<mat-option [value]="s">{{ statusLabel(s) }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td class="rr-saved__meta">{{ row.createdOn }}<br />{{ row.createdBy }}</td>
<td class="rr-saved__actions">
<button mat-button class="rr-saved__view" (click)="viewSaved(row)">
{{ viewingSaved?.id === row.id ? 'Close' : 'View' }}
</button>
<button mat-button class="rr-saved__del" (click)="deleteSaved(row)" title="Remove">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
<!--
═══ The opened snapshot ═══
Its own section below the list rather than in place of the live return: the reason to open a
saved return is almost always to compare it against where the bank stands now, which swapping
the live figures out would make impossible.
-->
@if (viewingSaved && savedData; as _) {
<div class="rr-card rr-card--section rr-card--saved" id="rr-saved-view">
<div class="rr-saved-head">
<div>
<h3 class="rr-section__title">
Saved return — {{ savedData.reportingDate }}
<span class="rr-badge rr-badge--future">{{ statusLabel(viewingSaved.status) }}</span>
</h3>
<p class="rr-section__hint">
Saved {{ viewingSaved.createdOn }} by {{ viewingSaved.createdBy || 'unknown' }}. These are the figures as
they stood when it was saved — not today's ledger, which may have moved since.
@if (viewingSaved.remarks) {
<em class="rr-saved__remarks">“{{ viewingSaved.remarks }}”</em>
}
</p>
</div>
<button mat-stroked-button (click)="closeSaved()">Close</button>
</div>
<div class="rr-summary">
<div class="rr-stat">
<span class="rr-stat__label">NDTL base</span>
<span class="rr-stat__value">{{ amount(savedData.ndtlAmount ?? savedData.dtlAmount) }}</span>
<span class="rr-stat__sub">as at {{ savedData.basis.dtlAsOnDate }}</span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">{{ savedData.rateType }} rate</span>
<span class="rr-stat__value">{{ savedData.ratePercentage }}%</span>
<span class="rr-stat__sub">as filed</span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">Required</span>
<span class="rr-stat__value">{{ amount(savedData.requiredAmount) }}</span>
<span class="rr-stat__sub">{{ savedData.ratePercentage }}% of NDTL</span>
</div>
<div class="rr-stat">
<span class="rr-stat__label">Maintained</span>
<span class="rr-stat__value">{{ amount(savedData.maintainedAmount) }}</span>
<span class="rr-stat__sub">on {{ savedData.basis.holdingsAsOnDate }}</span>
</div>
<div class="rr-stat" [class.rr-stat--good]="savedData.compliant" [class.rr-stat--bad]="!savedData.compliant">
<span class="rr-stat__label">{{ savedData.compliant ? 'Surplus' : 'Shortfall' }}</span>
<span class="rr-stat__value">{{ amount(savedSurplus) }}</span>
<span class="rr-stat__sub">{{ savedData.compliant ? 'Above requirement' : 'Below requirement' }}</span>
</div>
</div>
<table class="rr-table">
<thead>
<tr>
<th>Particulars</th>
<th>As at</th>
<th class="rr-table__num">Amount (INR)</th>
</tr>
</thead>
<tbody>
@for (line of savedData.lines; track line.configKey) {
<tr class="rr-line" [class.rr-line--unconfigured]="!line.configured">
<td>
<span class="rr-line__label">{{ line.label }}</span>
</td>
<td class="rr-line__date">{{ line.asOnDate }}</td>
<td class="rr-table__num">
@if (line.configured) {
{{ amount(line.amount) }}
} @else {
<span class="rr-dash">—</span>
}
</td>
</tr>
@for (account of line.accounts; track account.glAccountId) {
<tr class="rr-subline">
<td>
<span class="rr-subline__code">{{ account.glCode }}</span>
{{ account.name }}
@if (isPartial(account)) {
<span class="rr-subline__portion">
{{ account.weightPercentage }}% of {{ amount(account.accountBalance) }}
</span>
}
@if (account.liabilityClass) {
<span class="rr-subline__class">{{ account.liabilityClass | titlecase }}</span>
}
</td>
<td></td>
<td class="rr-table__num">{{ amount(account.balance) }}</td>
</tr>
}
}
</tbody>
</table>
@if (savedData.maintenance; as sm) {
<h4 class="rr-saved__subhead">Daily maintenance as filed — {{ sm.periodLabel || '' }}</h4>
<table class="rr-table">
<thead>
<tr>
<th>Date</th>
<th class="rr-table__num">Required</th>
<th class="rr-table__num">Actual</th>
<th class="rr-table__num">Surplus / (Shortfall)</th>
</tr>
</thead>
<tbody>
@for (day of sm.days; track day.date) {
<tr [class.rr-day--short]="day.amount < sm.dailyMinimumAmount">
<td>{{ day.date }}</td>
<td class="rr-table__num">{{ amount(day.required) }}</td>
<td class="rr-table__num">{{ amount(day.amount) }}</td>
<td class="rr-table__num" [class.rr-neg]="day.surplus < 0">{{ amount(day.surplus) }}</td>
</tr>
}
</tbody>
</table>
}
</div>
}
}
@if (toastVisible) {
<div class="rr-toast">{{ toastMsg }}</div>
}
</div>
@@ -0,0 +1,685 @@
/**
* 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;
.rr-container {
max-width: 1120px;
margin: 0 auto;
padding: 20px 16px;
}
.rr-loading {
padding: 36px;
text-align: center;
color: $gray-500;
font-size: 13px;
}
.rr-alert {
padding: 12px 16px;
border-radius: 8px;
font-size: 12.5px;
margin-bottom: 16px;
line-height: 1.6;
&--error {
background: #fce8e6;
color: $red;
}
&--warn {
background: #fef7e0;
color: $amber;
}
}
.rr-unmet {
margin: 8px 0 8px 18px;
padding: 0;
li {
margin-bottom: 4px;
}
// Sets up the list that follows: without it, a blank line reads as an error rather than as a
// head the bank has nothing under.
&__lead {
margin: 6px 0 0;
font-size: 12px;
font-weight: 400;
line-height: 1.6;
opacity: 0.9;
}
&__link {
display: inline-block;
margin-top: 4px;
font-weight: 600;
color: $primary;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
// ── Header ──
.rr-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
&__title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: $gray-900;
}
&__freq {
display: inline-block;
margin-top: 4px;
margin-right: 10px;
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #e8f0fe;
color: $primary;
border-radius: 20px;
padding: 3px 10px;
}
&__period {
font-size: 12px;
color: $gray-500;
}
&__actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
i {
margin-right: 6px;
}
}
}
.rr-date {
width: 180px;
}
// ── Tabs ──
.rr-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid $border;
margin-bottom: 16px;
}
.rr-tab {
display: inline-flex;
align-items: center;
gap: 8px;
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
color: $gray-500;
cursor: pointer;
font-family: inherit;
i {
font-size: 12px;
}
&:hover {
color: $gray-900;
}
&--on {
color: $primary;
border-bottom-color: $primary;
}
&__count {
font-size: 10.5px;
font-weight: 700;
background: #e8f0fe;
color: $primary;
border-radius: 20px;
padding: 1px 8px;
}
}
// ── Basis ──
.rr-basis {
background: $gray-50;
border: 1px solid $border;
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 16px;
&__title {
margin: 0 0 12px;
font-size: 13px;
font-weight: 700;
color: $gray-900;
}
&__row {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
// The General Information particulars. A grid rather than a stack: eleven one-line facts read as
// a block to scan, not as a list to work down, and auto-fit keeps them legible at any width.
&__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 12px 20px;
}
&__item {
min-width: 0;
&--muted {
opacity: 0.65;
}
}
&__label {
display: block;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $gray-500;
}
&__value {
font-size: 14px;
font-weight: 600;
color: $gray-900;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
// A particular nobody has maintained yet, shown as such rather than as a filled-in blank.
&--unset {
font-weight: 500;
font-style: italic;
color: $amber;
}
}
&__arrow {
color: $gray-500;
font-size: 12px;
}
&__note {
margin: 10px 0 0;
font-size: 11.5px;
color: $gray-500;
line-height: 1.6;
}
}
// ── Summary stats ──
.rr-summary {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.rr-stat {
flex: 1 1 180px;
background: #fff;
border: 1px solid $border;
border-radius: 10px;
padding: 12px 14px;
&__label {
display: block;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $gray-500;
margin-bottom: 4px;
}
&__value {
display: block;
font-size: 19px;
font-weight: 700;
color: $gray-900;
font-variant-numeric: tabular-nums;
}
&__sub {
display: block;
margin-top: 2px;
font-size: 11px;
color: $gray-500;
&--warn {
color: $amber;
font-weight: 600;
}
}
// The rate is still the shipped placeholder — the figures below it are only as good as it is.
&--provisional {
border-color: #fde68a;
background: #fffdf5;
}
// A shortfall is a regulatory breach, so it reads as one rather than as another number.
&--bad {
border-color: #f5c6c4;
background: #fffafa;
.rr-stat__value {
color: $red;
}
}
&--good {
border-color: #ceead6;
background: #fafffb;
.rr-stat__value {
color: $green;
}
}
}
// ── Table ──
.rr-card {
background: #fff;
border: 1px solid $border;
border-radius: 12px;
padding: 6px 16px 14px;
overflow-x: auto;
}
.rr-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: 10px;
border-bottom: 1px solid $border;
white-space: nowrap;
}
td {
padding: 9px 10px;
border-bottom: 1px solid $gray-200;
color: $gray-900;
}
&__num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
.rr-line {
&__label {
font-weight: 600;
}
&__date {
color: $gray-500;
font-size: 11.5px;
white-space: nowrap;
}
&--unconfigured {
background: #fffafa;
}
}
.rr-subline {
td {
padding-top: 5px;
padding-bottom: 5px;
font-size: 11.5px;
color: $gray-500;
border-bottom: none;
}
td:first-child {
padding-left: 26px;
}
&__code {
font-family: 'Roboto Mono', monospace;
font-size: 10.5px;
background: $gray-50;
border: 1px solid $border;
border-radius: 4px;
padding: 1px 5px;
margin-right: 6px;
}
// Only part of the account is taken, so the figure alongside is not its balance.
&__portion {
margin-left: 8px;
font-size: 10.5px;
font-weight: 600;
color: $amber;
background: #fef7e0;
border-radius: 20px;
padding: 1px 8px;
white-space: nowrap;
}
&__class {
margin-left: 6px;
font-size: 10px;
font-weight: 600;
color: $primary;
background: #e8f0fe;
border-radius: 20px;
padding: 1px 8px;
}
// An account pinned to the role but not in the total. Marked rather than merely blank, so it
// reads as deliberately left out instead of as an account that happens to be nil.
&--excluded td {
color: $amber;
}
&__excluded {
font-size: 10.5px;
font-style: italic;
margin-left: 8px;
color: $amber;
}
}
.rr-total {
td {
font-weight: 700;
background: #e8f0fe;
border-bottom: 1px solid #d2e3fc;
}
&--bad td {
background: #fce8e6;
color: $red;
border-bottom-color: #f5c6c4;
}
}
.rr-dash {
color: $gray-500;
}
// ── Sections: Memorandum, Annexures, fortnight maintenance ──
.rr-card--section {
margin-top: 16px;
padding: 16px;
}
.rr-section {
&__title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
color: $gray-900;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
&__hint {
margin: 0 0 14px;
font-size: 12px;
color: $gray-500;
line-height: 1.6;
}
}
// A day below the daily floor is a breach on its own, even where the average passes.
.rr-day--short td {
background: #fffafa;
}
.rr-ok {
font-size: 11px;
color: $green;
font-weight: 600;
}
// A shortfall on a day reads as one, rather than as a number with a minus sign in front.
.rr-neg {
color: $red;
font-weight: 600;
}
// ── Saved returns ──
.rr-empty {
padding: 22px;
text-align: center;
color: $gray-500;
font-size: 12.5px;
}
.rr-save-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
&__note {
flex: 1 1 340px;
}
}
.rr-saved {
&__period,
&__meta {
font-size: 11px;
color: $gray-500;
white-space: nowrap;
}
&__status {
width: 170px;
}
&__actions {
white-space: nowrap;
}
&__view {
font-size: 12px !important;
min-width: 0 !important;
color: $primary !important;
}
&__del {
min-width: 0 !important;
padding: 0 8px !important;
color: $gray-500 !important;
&:hover {
color: $red !important;
}
}
&__remarks {
display: block;
margin-top: 4px;
color: $gray-500;
}
&--open td {
background: #e8f0fe;
}
}
// The opened snapshot, in its own section. Tinted and edged so it reads as a different document
// from the live return above it rather than as more of the same page.
.rr-card--saved {
border-color: #c6dafc;
background: #fbfdff;
border-left: 4px solid $primary;
}
.rr-saved-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.rr-saved__subhead {
margin: 20px 0 8px;
font-size: 13px;
font-weight: 600;
color: $gray-900;
}
// ── Print ──
// The browser's print dialog is also its save-as-PDF, so one stylesheet serves both. Controls,
// navigation and the saved-returns list are dropped: a printed return is a statement, and a page
// carrying dead buttons reads as a screenshot of software rather than as a filing.
@media print {
.rr-noprint,
.rr-header__actions,
.rr-unmet__link {
display: none !important;
}
.rr-container {
max-width: none;
padding: 0;
}
.rr-card,
.rr-basis,
.rr-stat {
border-color: #999 !important;
box-shadow: none !important;
break-inside: avoid;
}
.rr-card {
overflow: visible !important;
}
// Header rows repeat on every printed page, so a table spilling over a page break stays readable.
.rr-table thead {
display: table-header-group;
}
.rr-table tr {
break-inside: avoid;
}
.rr-toast {
display: none !important;
}
// Backgrounds carry meaning here — a day below the floor is shaded, not merely labelled — so they
// must survive the print engine's default of dropping them to save ink.
.rr-container,
.rr-card,
.rr-basis,
.rr-stat,
.rr-table,
.rr-table th,
.rr-table td {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
.rr-badge {
display: inline-block;
margin-left: 8px;
font-size: 10px;
font-weight: 600;
border-radius: 20px;
padding: 2px 8px;
&--unmapped {
background: #fce8e6;
color: $red;
}
// The fortnight is still running, so the average is of the days elapsed so far.
&--partial {
background: #fef7e0;
color: $amber;
}
}
.rr-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: $gray-900;
color: #fff;
font-size: 12.5px;
padding: 10px 18px;
border-radius: 8px;
z-index: 1000;
}
@media (width <= 720px) {
.rr-header__actions {
width: 100%;
}
.rr-date {
flex: 1 1 100%;
width: auto;
}
}
@@ -12,6 +12,7 @@ import { Routes, RouterModule } from '@angular/router';
/** Routing Imports */
import { Route } from '../core/route/route.service';
import { tdsConfigResolver } from '../letter-mgmt/certificates/form-15gh/tds-config.service';
/** Custom Components */
import { ReportsComponent } from './reports.component';
@@ -84,12 +85,14 @@ const routes: Routes = [
{
path: 'tds-interest',
data: { title: 'Deposit Interest & TDS', breadcrumb: 'Deposit Interest & TDS' },
resolve: { tdsConfig: tdsConfigResolver },
loadComponent: () =>
import('../tds-reports/tds-interest-report.component').then((m) => m.TdsInterestReportComponent)
},
{
path: 'tds-remittance',
data: { title: 'TDS Remitted to Government', breadcrumb: 'TDS Remitted to Government' },
resolve: { tdsConfig: tdsConfigResolver },
loadComponent: () =>
import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent)
},
@@ -21,7 +21,7 @@
<div class="flex-100 layout-row-wrap responsive-column">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Saving reference'"
[accountTitle]="productLabel ? productLabel + ' reference' : 'Saving reference'"
[glAccount]="accountingMappings.savingsReferenceAccount"
[withTitle]="'40%'"
>
@@ -72,7 +72,7 @@
<div class="flex-100 layout-row-wrap responsive-column">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Saving control'"
[accountTitle]="productLabel ? productLabel + ' control' : 'Saving control'"
[glAccount]="accountingMappings.savingsControlAccount"
[withTitle]="'40%'"
>
@@ -81,7 +81,7 @@
<div class="flex-100 layout-row-wrap responsive-column">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Savings transfers in suspense'"
[accountTitle]="productLabel ? 'Transfers in suspense' : 'Savings transfers in suspense'"
[glAccount]="accountingMappings.transfersInSuspenseAccount"
[withTitle]="'40%'"
>
@@ -143,7 +143,7 @@
<div class="flex-100 layout-row-wrap responsive-column">
<mifosx-gl-account-display
class="flex-100"
[accountTitle]="'Interest on savings'"
[accountTitle]="productLabel ? 'Interest on ' + productLabel.toLowerCase() + 's' : 'Interest on savings'"
[glAccount]="accountingMappings.interestOnSavingsAccount"
[withTitle]="'40%'"
>
@@ -26,6 +26,8 @@ export class ViewSavingsAccountingDetailsComponent {
@Input() accountingRule: OptionData;
@Input() accountingMappings: any[] = [];
/** Product name for GL row titles (e.g. 'Fixed deposit'); omitted = savings wording. */
@Input() productLabel?: string;
isCashOrAccrualAccounting(): boolean {
if (this.accountingRule) {
@@ -226,8 +226,10 @@
<mat-spinner diameter="16"></mat-spinner>
<span>Submitting…</span>
} @else {
<mat-icon>check</mat-icon>
<span>Submit Request</span>
<ng-container>
<mat-icon>check</mat-icon>
<span>Submit Request</span>
</ng-container>
}
</button>
</mat-dialog-actions>
@@ -275,8 +275,10 @@
<mat-spinner diameter="16"></mat-spinner>
<span>Applying…</span>
} @else {
<mat-icon>check</mat-icon>
<span>Apply Collection</span>
<ng-container>
<mat-icon>check</mat-icon>
<span>Apply Collection</span>
</ng-container>
}
</button>
</mat-dialog-actions>

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