Compare commits

..
2 Commits
Author SHA1 Message Date
keshav c91dcf4d99 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-10-12 10:23:26 +05:30
keshav 2d2eac8d03 feat: recurring deposit enhancements, dashboard, chart, and account view updates 2026-10-12 10:23:10 +05:30
75 changed files with 4228 additions and 3003 deletions
+5
View File
@@ -85,6 +85,11 @@ export class AccountsService {
return this.http.get('/fixeddepositaccounts/maturity-dashboard');
}
/** RD Dashboard: running RDs with their installment position, closures and penalty income for the month. */
getRecurringDepositDashboard(): Observable<any> {
return this.http.get('/recurringdepositaccounts/dashboard');
}
getRecurringDepositAccounts(params: AccountListParams = {}): Observable<any> {
let httpParams = new HttpParams()
.set('paged', 'true')
+2
View File
@@ -64,6 +64,7 @@ import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module';
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
import { RdDashboardModule } from './rd-dashboard/rd-dashboard.module';
import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
@@ -178,6 +179,7 @@ export function HttpLoaderFactory(http: HttpClient) {
DeafModule,
SavingsDailyInterestModule,
FdDashboardModule,
RdDashboardModule,
DicgcModule,
FiuModule,
GlReportsModule,
@@ -5246,7 +5246,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.showAlert('error', 'Invalid Account Type', [
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
'Only Savings (SD) and Current (CA) accounts are permitted.',
'GL, FD, RD, SHR accounts are not allowed — use Disbursement/Collection instead.'
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
'RD installments are paid through a cashier Receipt or Auto Debit.'
]);
return;
}
@@ -5300,6 +5301,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rows.length >= 2 &&
this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer')
) {
// A Recurring Deposit is never paid this way: these requests post straight into the account, so the
// installment schedule never hears of the money -- installments stay unpaid, Auto Debit collects them
// again and the maturity amount comes out wrong. RD installments come in through the cashier's
// Receipt or the RD's own Auto Debit.
const rdRows = this.rows.filter((r) => r.acTyp === 'RD');
if (rdRows.length > 0) {
this.showAlert('error', 'Recurring Deposit Not Allowed', [
`RD account(s) ${rdRows.map((r) => r.acNo).join(', ')} cannot be used in a Disbursement, Collection or Fund Transfer.`,
'Pay RD installments through a cashier Receipt, or by Auto Debit from the linked savings account.'
]);
return;
}
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
@@ -591,16 +591,13 @@
<i class="fa fa-calendar section-header__icon"></i>
<h3 class="section-header__title">{{ 'labels.heading.Recurring Deposit Accounts' | translate }}</h3>
</div>
<div class="section-header__right">
<button mat-raised-button color="primary" (click)="toggleRecurringAccountsOverview()">
{{ viewAccountsLabel(showClosedRecurringAccounts) | translate }}
</button>
</div>
</div>
@if (!showClosedRecurringAccounts) {
<!-- Active and ended recurring deposits share one table; the Status column says how each one ended (matured or
closed early), so the client's RD history stays visible without switching views. -->
@if (savingAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring'">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
@@ -614,6 +611,15 @@
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{ rdStatusLabel(element) }}</span>
@if (element.timeline?.closedOnDate) {
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="Last Active">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
@@ -638,7 +644,7 @@
<i class="fa fa-check"></i>
</button>
}
@if (!element.status.submittedAndPendingApproval && !element.status.active) {
@if (element.status.approved) {
<button
class="account-action-button"
mat-raised-button
@@ -664,7 +670,7 @@
</ng-container>
<ng-container matColumnDef="no-data">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="openSavingsColumns.length">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="recurringColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">
@@ -674,67 +680,17 @@
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="openSavingsColumns"></tr>
<tr mat-header-row *matHeaderRowDef="recurringColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: openSavingsColumns"
*matRowDef="let row; columns: recurringColumns"
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
class="select-row"
></tr>
<tr
mat-footer-row
*matFooterRowDef="
(savingAccounts | accountsFilter: 'saving' : 'open' : 'isRecurring')?.length === 0 ? ['no-data'] : []
"
></tr>
</table>
</div>
}
<!-- Closed Recurring Deposit Accounts -->
@if (showClosedRecurringAccounts) {
<div class="table-wrapper">
<table mat-table [dataSource]="savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring'">
<ng-container matColumnDef="Account No">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account No' | translate }}</th>
<td mat-cell *matCellDef="let element">
<i class="fa fa-stop" [ngClass]="element.status.code | statusLookup"></i>
<mifosx-account-number accountNo="{{ element.accountNo }}"></mifosx-account-number>
</td>
</ng-container>
<ng-container matColumnDef="Saving Account">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Recurring Deposit Product' | translate }}</th>
<td mat-cell *matCellDef="let element">
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
</td>
</ng-container>
<ng-container matColumnDef="Closed Date">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
</ng-container>
<ng-container matColumnDef="no-data">
<td mat-footer-cell *matFooterCellDef [attr.colspan]="closedSavingsColumns.length">
<div class="table-empty-state">
<i class="fa fa-folder-open-o table-empty-state__icon"></i>
<p class="table-empty-state__text">
{{ 'labels.messages.No Closed Recurring Deposit Accounts Available' | translate }}
</p>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="closedSavingsColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: closedSavingsColumns"
[routerLink]="['../', 'recurring-deposits-accounts', row.id, 'general']"
class="select-row"
></tr>
<tr
mat-footer-row
*matFooterRowDef="
(savingAccounts | accountsFilter: 'saving' : 'closed' : 'isRecurring')?.length === 0 ? ['no-data'] : []
(savingAccounts | accountsFilter: 'saving' : 'all' : 'isRecurring')?.length === 0 ? ['no-data'] : []
"
></tr>
</table>
@@ -419,3 +419,24 @@ table[mat-table] {
font-size: 24px;
z-index: 1;
}
.rd-status {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
background: #dcfce7;
color: #166534;
&--ended {
background: #e2e8f0;
color: #475569;
}
}
.rd-status__date {
margin-top: 2px;
font-size: 11px;
color: #64748b;
}
@@ -187,6 +187,15 @@ export class GeneralTabComponent implements OnDestroy {
'Saving Account',
'Closed Date'
];
/** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */
recurringColumns: string[] = [
'Account No',
'Saving Account',
'Status',
'Last Active',
'Balance',
'Actions'
];
/** Open Shares Accounts Columns */
openSharesColumns: string[] = [
'Account No',
@@ -475,6 +484,30 @@ export class GeneralTabComponent implements OnDestroy {
);
}
/** Where a recurring deposit stands - for one that has ended, how it ended. */
rdStatusLabel(account: any): string {
switch (account?.status?.code) {
case 'savingsAccountStatusType.active':
return 'Active';
case 'savingsAccountStatusType.matured':
return 'Matured (not closed)';
case 'savingsAccountStatusType.submitted.and.pending.approval':
return 'Pending Approval';
case 'savingsAccountStatusType.approved':
return 'Approved';
case 'savingsAccountStatusType.closed':
return 'Matured & Closed';
case 'savingsAccountStatusType.pre.mature.closure':
return 'Premature Closed';
case 'savingsAccountStatusType.rejected':
return 'Rejected';
case 'savingsAccountStatusType.withdrawn.by.applicant':
return 'Withdrawn by Client';
default:
return account?.status?.value ?? '';
}
}
viewAccountsLabel(closed: boolean): string {
if (closed) {
return 'labels.buttons.View Active Accounts';
@@ -377,6 +377,8 @@
</button>
}
@if (selectedTabIndex < totalTabs - 1) {
<!-- Tooltip sits on a wrapper: a disabled button fires no mouse events, so it can't host it. -->
<span [matTooltip]="currentStepBlockerMessage" [matTooltipDisabled]="!currentStepBlockerMessage">
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="savingClient || !currentStepValid">
@if (savingClient) {
<mat-progress-spinner diameter="18" mode="indeterminate" class="m-r-10"></mat-progress-spinner>
@@ -390,6 +392,7 @@
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
}
</button>
</span>
}
@if (selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" [routerLink]="['../']">
@@ -393,6 +393,12 @@ export class EditClientComponent {
if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) {
return 'Verify the OTP sent to both the old and new mobile number/email before saving.';
}
if (this.selectedTabIndex === 0 && this.clientGeneralForm && !this.clientGeneralForm.valid) {
const labels = this.invalidGeneralFieldLabels();
if (labels.length) {
return `Please fill/correct: ${labels.join(', ')}.`;
}
}
if (this.selectedTabIndex === 1) {
const labels = this.clientKycStep?.invalidFieldLabels || [];
if (labels.length) {
@@ -408,6 +414,42 @@ export class EditClientComponent {
return 'Please fill all mandatory fields on this tab to continue.';
}
/**
* Readable names of the General-tab controls that are invalid. Older clients can be missing
* fields that are now mandatory (e.g. mobile/email), which leaves Save disabled with nothing
* on screen saying why — this feeds the button tooltip.
*/
private invalidGeneralFieldLabels(): string[] {
const labels: Record<string, string> = {
officeId: 'Office',
legalFormId: 'Legal Form',
genderId: 'Gender',
mobileNo: 'Mobile No',
alternateMobileNo: 'Alternate Mobile No',
emailAddress: 'Email',
dateOfBirth: 'Date of Birth',
submittedOnDate: 'Submitted On',
activationDate: 'Activation Date',
savingsProductId: 'Savings Product',
firstname: 'First Name',
middlename: 'Middle Name',
lastname: 'Last Name',
fullname: 'Name',
monthlyIncome: 'Monthly Income',
annualIncome: 'Annual Income',
monthlyTurnover: 'Monthly Turnover',
annualTurnover: 'Yearly Turnover',
agricultureIncome: 'Agriculture Income',
nonAgricultureIncome: 'Non-Agriculture Income',
guardianDetails: 'Guardian',
clientNonPersonDetails: 'Entity Details'
};
const controls = this.clientGeneralForm?.controls || {};
return Object.keys(controls)
.filter((name) => controls[name].invalid)
.map((name) => labels[name] || name);
}
/** Whether the current tab has unsaved edits */
isCurrentTabEdited(): boolean {
return this.tabEditMode[this.selectedTabIndex] === true;
+6 -1
View File
@@ -48,7 +48,12 @@ export class CacheInterceptor implements HttpInterceptor {
'/cashreconciliation',
'/cashsettlement',
'/cashtransfer',
'/branchday'
'/branchday',
// A deposit's lien moves with every repayment of the loan it secures, and decides whether the
// deposit may be closed at all - a stale copy would show a released lien as still blocking.
'/liens',
// The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken.
'/recurringdepositaccounts/dashboard'
];
private isCacheable(url: string): boolean {
+3
View File
@@ -1220,6 +1220,9 @@
<button mat-menu-item tabindex="0" [routerLink]="['/fd-dashboard']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> FD Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/rd-dashboard']">
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> RD Dashboard
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> FD Interest Report
</button>
@@ -195,6 +195,13 @@ export class CreateRecurringDepositsAccountComponent {
});
return;
}
if (this.recurringDepositsAccountProductTemplate?.introducerRequired && !detailsStep?.introducerId) {
this.alertService.alert({
type: 'Error',
message: 'This product requires an introducer. Please choose an existing customer as introducer before submitting.'
});
return;
}
if (!this.selectedTenureValue || this.selectedTenureFrequencyId === null) {
this.alertService.alert({
type: 'Error',
@@ -209,6 +216,39 @@ export class CreateRecurringDepositsAccountComponent {
});
return;
}
// Same limits the server enforces - checked here so the message names the numbers before anything is sent.
const minInstallment = Number(this.recurringDepositsAccountProductTemplate?.minDepositAmount) || 0;
const maxInstallment = Number(this.recurringDepositsAccountProductTemplate?.maxDepositAmount) || 0;
if (minInstallment > 0 && this.selectedDepositAmount < minInstallment) {
this.alertService.alert({
type: 'Error',
message: `Installment ${this.selectedDepositAmount} is below this product's minimum of ${minInstallment}.`
});
return;
}
if (maxInstallment > 0 && this.selectedDepositAmount > maxInstallment) {
this.alertService.alert({
type: 'Error',
message: `Installment ${this.selectedDepositAmount} is above this product's maximum of ${maxInstallment}.`
});
return;
}
if (
this.recurringDepositsAccountCurrencyStep?.autoDebitFacility &&
detailsStep?.selectedLinkedAccount?.accountType !== 'SAVINGS'
) {
this.alertService.alert({
type: 'Error',
message:
'Auto Debit collects each installment from the linked account. Link a Savings account under Deposit Account Link, or turn Auto Debit off.'
});
return;
}
const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError;
if (lienError) {
this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` });
return;
}
if (!detailsStep?.selectedCustomerSegment) {
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
return;
@@ -255,8 +295,16 @@ export class CreateRecurringDepositsAccountComponent {
isCalendarInherited: pt?.isCalendarInherited ?? false,
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
allowWithdrawal: pt?.allowWithdrawal ?? false,
adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false
// Money paid ahead of a due date counts towards the next installments. Without this an advance cash
// payment stayed unallocated: the schedule kept showing the installment unpaid, so Auto Debit collected it
// a second time and the missed-installment penalty charged it. The RD product form has no such option, so
// the product's value was always false.
adjustAdvanceTowardsFuturePayments: true,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false,
// Lien Marked products only - the server ignores it otherwise and caps it at the product's limit.
...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null
? { lienPercentage: Number(currencyStep.lienPercentage) }
: {})
};
// Charges from product template
@@ -233,6 +233,11 @@ export class EditRecurringDepositAccountComponent {
});
return;
}
const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError;
if (lienError) {
this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` });
return;
}
if (!detailsStep?.selectedCustomerSegment) {
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
return;
@@ -275,8 +280,13 @@ export class EditRecurringDepositAccountComponent {
isCalendarInherited: pt?.isCalendarInherited ?? false,
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
allowWithdrawal: pt?.allowWithdrawal ?? false,
adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false
// See create-recurring-deposits-account: advance payments must count towards the next installments.
adjustAdvanceTowardsFuturePayments: true,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false,
// Lien Marked products only - the server ignores it otherwise and caps it at the product's limit.
...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null
? { lienPercentage: Number(currencyStep.lienPercentage) }
: {})
};
const rawCharges: any[] = pt?.charges ?? [];
@@ -70,8 +70,9 @@ export class ActivateRecurringDepositsAccountComponent implements OnInit {
*/
createActivateRecurringDepositsAccountForm() {
this.activateRecurringDepositsAccountForm = this.formBuilder.group({
// Defaults to the business date (the branch's working day), which is what the account is normally activated on.
activatedOnDate: [
'',
this.settingsService.businessDate,
Validators.required
]
});
@@ -72,8 +72,9 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit {
*/
createApproveRecurringDepositsAccountForm() {
this.approveRecurringDepositsAccountForm = this.formBuilder.group({
// Defaults to the business date (the branch's working day), which is what the account is normally approved on.
approvedOnDate: [
'',
this.settingsService.businessDate,
Validators.required
],
note: ['']
@@ -13,6 +13,16 @@
<div class="layout-column">
<h3 class="mat-h3 flex-fill">{{ title }}</h3>
@if (totalLienAmount > 0) {
<p class="lien-warn">
This deposit is pledged as loan security (lien {{ totalLienAmount | number: '1.2-2' }} -
@for (lien of liens; track lien.loanId; let last = $last) {
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
). It cannot be closed until the loan is repaid or the guarantee is released.
</p>
}
<mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
<input
@@ -154,7 +164,7 @@
<button type="button" mat-raised-button [routerLink]="['../general']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid">
<button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid || totalLienAmount > 0">
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
@@ -10,6 +10,12 @@
max-width: 37rem;
}
.lien-warn {
margin: 0 0 16px;
font-size: 13px;
color: #c62828;
}
.expandcollapsebutton {
margin-top: -7px;
margin-left: 2%;
@@ -64,6 +64,9 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
maxDate = new Date();
/** Close Recurring Deposit Account form. */
closeRecurringDepositForm: UntypedFormGroup;
/** Loans this deposit secures. While any remain, the server refuses to close it. */
liens: any[] = [];
totalLienAmount = 0;
/**
* Retrieves action details template data from `resolve`
@@ -79,6 +82,11 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
this.maturityAmount = data.recurringDepositsAccountActionData.maturityAmount;
this.onAccountClosureOptions = data.recurringDepositsAccountActionData.onAccountClosureOptions;
this.paymentTypes = data.recurringDepositsAccountActionData.paymentTypeOptions;
// A matured deposit closes on or after its maturity date - the server refuses anything earlier.
const maturityDate = data.recurringDepositsAccountActionData.maturityDate;
if (Array.isArray(maturityDate) && maturityDate.length >= 3) {
this.minDate = new Date(maturityDate[0], maturityDate[1] - 1, maturityDate[2]);
}
if (data.recurringDepositsAccountActionData.maturityAmount) {
this.title = 'Recurring Deposit Closure on Maturity';
} else {
@@ -92,6 +100,10 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
this.maxDate = this.settingsService.businessDate;
this.createcloseRecurringDepositForm();
this.addTransferDetails();
this.recurringDepositsService.getRecurringDepositLiens(this.accountId).subscribe((response: any) => {
this.liens = response?.liens ?? [];
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
});
}
/**
@@ -99,8 +111,10 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
*/
createcloseRecurringDepositForm() {
this.closeRecurringDepositForm = this.formBuilder.group({
// The business date, not the computer's clock: the branch's working day can differ from the calendar day,
// and closing on the clock's date (before maturity, or before the deposit's last installment) fails.
closedOnDate: [
new Date(),
this.settingsService.businessDate,
Validators.required
],
maturityAmount: [{ value: this.maturityAmount, disabled: true }],
@@ -11,6 +11,51 @@
<form [formGroup]="prematureCloseRecurringDepositsAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
@if (account) {
<div class="pc-context">
<div class="pc-row">
<span>Account</span><b>{{ account.accountNo }}</b>
</div>
<div class="pc-row">
<span>Current balance</span><b>{{ currencySymbol }} {{ balance | number: '1.2-2' }}</b>
</div>
@if (account.maturityDate) {
<div class="pc-row">
<span>Maturity date</span><b>{{ account.maturityDate | dateFormat }}</b>
</div>
}
@if (account.maturityAmount != null) {
<div class="pc-row">
<span>Amount at maturity</span><b>{{ currencySymbol }} {{ account.maturityAmount | number: '1.2-2' }}</b>
</div>
}
<div class="pc-row">
<span>Pre-closure penalty</span>
<b>
@if (account.preClosurePenalApplicable) {
{{ account.preClosurePenalInterest }}% less interest
@if (account.preClosurePenalInterestOnType?.value) {
({{ account.preClosurePenalInterestOnType.value }})
}
} @else {
None
}
</b>
</div>
</div>
}
@if (totalLienAmount > 0) {
<p class="pc-warn">
This deposit is pledged as loan security (lien {{ currencySymbol }}
{{ totalLienAmount | number: '1.2-2' }} -
@for (lien of liens; track lien.loanId; let last = $last) {
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
). It cannot be closed until the loan is repaid or the guarantee is released.
</p>
}
<mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
<input
@@ -23,6 +68,7 @@
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
<mat-hint>Must be before the maturity date - on or after it, use Close instead.</mat-hint>
@if (prematureCloseRecurringDepositsAccountForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
@@ -30,6 +76,89 @@
</mat-error>
}
</mat-form-field>
@if (reasonOptions.length > 1) {
<mat-form-field subscriptSizing="dynamic">
<mat-label>Closure reason</mat-label>
<mat-select formControlName="prematureClosureReasonId">
@for (reason of reasonOptions; track reason.id) {
<mat-option [value]="reason.id">{{ reason.label }}</mat-option>
}
</mat-select>
<mat-hint>{{ selectedReasonHint }}</mat-hint>
</mat-form-field>
}
<div class="pc-calc">
<button
type="button"
mat-stroked-button
color="primary"
(click)="calculate()"
[disabled]="
calculating || totalLienAmount > 0 || !prematureCloseRecurringDepositsAccountForm.value.closedOnDate
"
>
{{ calculating ? 'Calculating…' : 'Calculate Payout' }}
</button>
</div>
@if (preview) {
<div class="pc-result">
<div class="pc-row pc-row--total">
<span>Payable on premature closure</span>
<b>{{ currencySymbol }} {{ preview.payoutAmount | number: '1.2-2' }}</b>
</div>
@if (shortfallVsMaturity != null && shortfallVsMaturity > 0) {
<div class="pc-row pc-row--muted">
<span>Less than at maturity by</span>
<b>{{ currencySymbol }} {{ shortfallVsMaturity | number: '1.2-2' }}</b>
</div>
}
<p class="pc-note">
Paid by transfer to the client's savings account - a recurring deposit cannot be broken for cash.
</p>
</div>
@if (destinationAccounts.length === 0) {
<p class="pc-warn">
This client has no active savings account to receive the proceeds. Open one first, then close this
deposit.
</p>
} @else {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (savings of destinationAccounts; track savings.id) {
<mat-option [value]="savings.id">
{{ savings.accountNo }} - {{ savings.productName }}
@if (savings.owner) {
(nominee: {{ savings.owner }})
} @else if (savings.id === account?.linkedAccountId) {
(linked)
}
</mat-option>
}
</mat-select>
@if (prematureCloseRecurringDepositsAccountForm.controls.toSavingsAccountId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput cdkTextareaAutosize formControlName="note"></textarea>
</mat-form-field>
}
}
</div>
</mat-card-content>
@@ -37,8 +166,8 @@
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!prematureCloseRecurringDepositsAccountForm">
{{ 'labels.buttons.Confirm' | translate }}
<button mat-raised-button color="primary" [disabled]="!canConfirm">
{{ submitting ? 'Closing…' : ('labels.buttons.Confirm' | translate) }}
</button>
</mat-card-actions>
</form>
@@ -9,3 +9,45 @@
.container {
max-width: 37rem;
}
.pc-context,
.pc-result {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
margin-bottom: 16px;
border: 1px solid rgb(128 128 128 / 30%);
border-radius: 6px;
}
.pc-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13px;
&--total {
font-size: 15px;
}
&--muted {
opacity: 0.75;
}
}
.pc-calc {
margin: 4px 0 16px;
}
.pc-note {
margin: 4px 0 0;
font-size: 12px;
opacity: 0.75;
}
.pc-warn {
margin: 0 0 16px;
font-size: 13px;
color: #c62828;
}
@@ -8,25 +8,41 @@
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
/** Custom Services */
import { RecurringDepositsService } from '../../recurring-deposits.service';
import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** DepositAccountOnClosureType.TRANSFER_TO_SAVINGS - the only way a broken RD may be paid out. */
const TRANSFER_TO_SAVINGS = 200;
/** PrematureClosureReasonType values the RD closure understands. */
const REASON_NORMAL = 100;
const REASON_DECEASED = 400;
const REASON_NOMINEE_HEIR = 500;
/**
* Premature Close Recurring Deposits Account Component
*
* The server will only break a recurring deposit early by transferring the proceeds to one of the client's savings
* accounts (no cash payout, no reinvest), and it needs to be told so: onAccountClosureId and toSavingsAccountId are
* mandatory. This screen used to send only the closure date, so every premature closure was rejected. It now asks the
* server what the deposit would pay on the chosen date (calculatePrematureAmount, which applies the product's
* pre-closure penalty) and only lets the closure be confirmed against that exact figure.
*/
@Component({
selector: 'mifosx-premature-close-recurring-deposit-account',
templateUrl: './premature-close-recurring-deposit-account.component.html',
styleUrls: ['./premature-close-recurring-deposit-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
CdkTextareaAutosize
]
})
export class PrematureCloseRecurringDepositAccountComponent implements OnInit {
@@ -36,72 +52,263 @@ export class PrematureCloseRecurringDepositAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private clientsService = inject(ClientsService);
/** Minimum date allowed. */
/** Savings accounts of this deposit's nominees - offered as destinations only for a deceased holder. */
nomineeSavingsAccounts: any[] = [];
/** Minimum date allowed: the deposit's activation date once loaded. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
/** Maximum date allowed: the business date, and never the maturity date or later (that is a normal Close). */
maxDate = new Date();
/** premature close Recurring Deposits Account form. */
prematureCloseRecurringDepositsAccountForm: UntypedFormGroup;
/** Recurring Deposits Account Id */
accountId: any;
/** The deposit being closed, for the context panel and the default destination account. */
account: any;
/** Result of the last calculatePrematureAmount call, and the date it was calculated for. */
preview: { payoutAmount: number; savingsAccounts: any[]; closureOptionId: number } | null = null;
previewedFor: string | null = null;
calculating = false;
submitting = false;
/** Loans this deposit secures. While any remain, the server refuses to close it. */
liens: any[] = [];
totalLienAmount = 0;
/**
* @param {FormBuilder} formBuilder Form Builder
* @param {RecurringDepositsService} recurringDepositsService Recurring Deposits Service
* @param {Dates} dateUtils Date Utils
* @param {ActivatedRoute} route Activated Route
* @param {Router} router Router
* @param {SettingsService} settingsService Settings Service
*/
constructor() {
this.accountId = this.route.parent.snapshot.params['recurringDepositAccountId'];
}
/**
* Creates the premature close recurring deposits form.
*/
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createprematureCloseRecurringDepositsAccountForm();
}
/**
* Creates the premature close recurring deposits account form.
*/
createprematureCloseRecurringDepositsAccountForm() {
this.prematureCloseRecurringDepositsAccountForm = this.formBuilder.group({
closedOnDate: [
'',
Validators.required
]
this.recurringDepositsService.getRecurringDepositsAccountData(this.accountId).subscribe((account: any) => {
this.account = account;
const activatedOn = this.toDate(account?.timeline?.activatedOnDate);
if (activatedOn) {
this.minDate = activatedOn;
}
const maturity = this.toDate(account?.maturityDate);
if (maturity) {
const dayBeforeMaturity = new Date(maturity);
dayBeforeMaturity.setDate(dayBeforeMaturity.getDate() - 1);
if (dayBeforeMaturity < this.maxDate) {
this.maxDate = dayBeforeMaturity;
}
}
});
this.recurringDepositsService.getRecurringDepositLiens(this.accountId).subscribe((response: any) => {
this.liens = response?.liens ?? [];
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
});
// A different date or reason is a different payout - the old preview no longer describes what would happen.
this.prematureCloseRecurringDepositsAccountForm.get('closedOnDate').valueChanges.subscribe(() => {
this.preview = null;
this.previewedFor = null;
});
this.prematureCloseRecurringDepositsAccountForm.get('prematureClosureReasonId').valueChanges.subscribe((reason: number) => {
this.preview = null;
this.previewedFor = null;
if (reason === REASON_DECEASED) {
this.loadNomineeSavingsAccounts();
}
});
}
/**
* Submits the form and premature closes the recurring deposit account,
* if successful redirects to the recurring deposit account.
*/
submit() {
const prematureCloseRecurringDepositsAccountFormData = this.prematureCloseRecurringDepositsAccountForm.value;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const prevClosedOnDate: Date = this.prematureCloseRecurringDepositsAccountForm.value.closedOnDate;
if (prematureCloseRecurringDepositsAccountFormData.closedOnDate instanceof Date) {
prematureCloseRecurringDepositsAccountFormData.closedOnDate = this.dateUtils.formatDate(
prevClosedOnDate,
dateFormat
);
/** Reasons this deposit's product allows; Normal is always there. */
get reasonOptions(): Array<{ id: number; label: string; hint: string }> {
const options = [{ id: REASON_NORMAL, label: 'Normal', hint: "Pre-closure penalty as per the deposit's terms." }];
if (this.account?.noPenaltyNomineeHeirClosure) {
options.push({ id: REASON_NOMINEE_HEIR, label: 'Nominee / legal heir claim', hint: 'No pre-closure penalty.' });
}
const data = {
...prematureCloseRecurringDepositsAccountFormData,
dateFormat,
locale
};
this.recurringDepositsService
.executeRecurringDepositsAccountCommand(this.accountId, 'prematureClose', data)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
if (this.account?.deceasedSettlement) {
options.push({
id: REASON_DECEASED,
label: 'Account holder deceased',
hint: "No penalty, allowed inside lock-in; may be paid to a nominee's savings account."
});
}
return options;
}
get selectedReasonHint(): string {
const reason = this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId;
return this.reasonOptions.find((option) => option.id === reason)?.hint ?? '';
}
/** The client's own savings accounts, plus - for a deceased holder - the nominees' savings accounts. */
get destinationAccounts(): any[] {
const own = (this.preview?.savingsAccounts ?? []).map((acc: any) => ({ ...acc, owner: null }));
if (this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId !== REASON_DECEASED) {
return own;
}
const ownIds = new Set(own.map((acc: any) => acc.id));
return [
...own,
...this.nomineeSavingsAccounts.filter((acc: any) => !ownIds.has(acc.id))
];
}
private loadNomineeSavingsAccounts() {
this.nomineeSavingsAccounts = [];
const clientId = this.account?.clientId;
if (!clientId || !this.account?.accountNo) {
return;
}
this.clientsService.getClientNominees(clientId, this.account.accountNo, 'RD').subscribe((nominees: any) => {
const list: any[] = Array.isArray(nominees) ? nominees : (nominees?.pageItems ?? []);
list
.filter((nominee: any) => nominee?.nomineeClientId && nominee.isActive !== false)
.forEach((nominee: any) => {
this.clientsService.getClientAccountData(nominee.nomineeClientId).subscribe((accounts: any) => {
const savings = (accounts?.savingsAccounts ?? []).filter(
(acc: any) => acc?.depositType?.id === 100 && acc?.status?.active
);
this.nomineeSavingsAccounts = [
...this.nomineeSavingsAccounts,
...savings.map((acc: any) => ({
id: acc.id,
accountNo: acc.accountNo,
productName: acc.productName,
owner: nominee.nomineeDisplayName
}))
];
});
});
});
}
createprematureCloseRecurringDepositsAccountForm() {
this.prematureCloseRecurringDepositsAccountForm = this.formBuilder.group({
closedOnDate: [
this.settingsService.businessDate,
Validators.required
],
prematureClosureReasonId: [REASON_NORMAL],
toSavingsAccountId: [
null,
Validators.required
],
transferDescription: ['Premature closure of recurring deposit'],
note: ['']
});
}
get balance(): number | null {
return this.account?.summary?.accountBalance ?? null;
}
get currencySymbol(): string {
return this.account?.currency?.displaySymbol || this.account?.currency?.code || '';
}
/** What breaking early costs compared with letting the deposit run to maturity, when both are known. */
get shortfallVsMaturity(): number | null {
const maturityAmount = this.account?.maturityAmount;
if (!this.preview || maturityAmount == null) {
return null;
}
return Number(maturityAmount) - Number(this.preview.payoutAmount);
}
get canConfirm(): boolean {
return (
this.totalLienAmount <= 0 &&
!!this.preview &&
this.previewedFor === this.formattedClosedOnDate &&
this.prematureCloseRecurringDepositsAccountForm.valid &&
!this.submitting
);
}
private get formattedClosedOnDate(): string | null {
const value = this.prematureCloseRecurringDepositsAccountForm.value.closedOnDate;
if (!value) {
return null;
}
return value instanceof Date ? this.dateUtils.formatDate(value, this.settingsService.dateFormat) : value;
}
/** Asks the server what the deposit pays if broken on the chosen date. */
calculate() {
const closedOnDate = this.formattedClosedOnDate;
if (!closedOnDate) {
return;
}
this.calculating = true;
const data = {
closedOnDate,
prematureClosureReasonId: this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId,
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
this.recurringDepositsService.executeRecurringDepositsAccountCommand(this.accountId, 'calculatePrematureAmount', data).subscribe({
next: (response: any) => {
const savingsAccounts = response?.savingsAccounts ?? [];
const closureOptionId =
(response?.onAccountClosureOptions ?? []).find((option: any) => option.id === TRANSFER_TO_SAVINGS)?.id ??
TRANSFER_TO_SAVINGS;
this.preview = { payoutAmount: response?.maturityAmount, savingsAccounts, closureOptionId };
this.previewedFor = closedOnDate;
this.calculating = false;
this.selectDefaultDestination(savingsAccounts);
},
error: () => {
this.calculating = false;
}
});
}
/** The deposit's own linked savings account, when it has one the client can still receive into. */
private selectDefaultDestination(savingsAccounts: any[]) {
const control = this.prematureCloseRecurringDepositsAccountForm.get('toSavingsAccountId');
if (control.value && savingsAccounts.some((acc: any) => acc.id === control.value)) {
return;
}
const linkedId = this.account?.linkedAccountId;
const linked = savingsAccounts.find((acc: any) => acc.id === linkedId);
control.setValue(linked?.id ?? (savingsAccounts.length === 1 ? savingsAccounts[0].id : null));
}
submit() {
if (!this.canConfirm) {
return;
}
const formValue = this.prematureCloseRecurringDepositsAccountForm.value;
const data: any = {
closedOnDate: this.formattedClosedOnDate,
onAccountClosureId: this.preview.closureOptionId,
prematureClosureReasonId: formValue.prematureClosureReasonId,
toSavingsAccountId: formValue.toSavingsAccountId,
transferDescription: formValue.transferDescription,
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
if (formValue.note) {
data.note = formValue.note;
}
this.submitting = true;
this.recurringDepositsService.executeRecurringDepositsAccountCommand(this.accountId, 'prematureClose', data).subscribe({
next: () => {
this.router.navigate(['../../'], { relativeTo: this.route });
},
error: () => {
this.submitting = false;
}
});
}
/** API dates arrive as [yyyy, m, d] arrays or ISO strings. */
private toDate(value: any): Date | null {
if (!value) {
return null;
}
if (Array.isArray(value)) {
return new Date(value[0], value[1] - 1, value[2]);
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
}
@@ -30,6 +30,14 @@
@if (actions['Close']) {
<mifosx-close-recurring-deposits-account></mifosx-close-recurring-deposits-account>
}
@if (actions['Deposit'] || actions['Withdrawal']) {
<!-- A direct deposit from the account screen skips the manager's approval; RD installments come in through the
cashier's Receipt or Auto Debit instead, so the Deposit action is not offered here even by URL. -->
@if (actions['Deposit']) {
<p>
RD installments cannot be deposited from the account screen. Use a cashier Receipt, or Auto Debit from the linked
savings account.
</p>
}
@if (actions['Withdrawal']) {
<mifosx-deposit-recurring-deposits-account [currency]="currency"></mifosx-deposit-recurring-deposits-account>
}
@@ -30,11 +30,50 @@
<div class="racc-alert racc-alert--editable">
<div class="racc-alert__content">
<h4>Auto Debit Facility</h4>
<p>Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.</p>
@if (recurringDepositsAccountProductTemplate?.autoDebitFacility) {
<p>
Collect each installment automatically on its due date from the linked Savings account (Deposit Account
Link, Details tab). A short balance leaves it overdue and it is retried on the next run.
</p>
} @else {
<p>This product does not offer Auto Debit.</p>
}
</div>
<mat-slide-toggle [(ngModel)]="autoDebitFacility" color="primary"></mat-slide-toggle>
<mat-slide-toggle
[(ngModel)]="autoDebitFacility"
[disabled]="!recurringDepositsAccountProductTemplate?.autoDebitFacility"
color="primary"
></mat-slide-toggle>
</div>
@if (lienAllowed) {
<div class="racc-alert racc-alert--editable">
<div class="racc-alert__content">
<h4>Loan against this RD (Lien)</h4>
<p>
How much of the RD balance can be given as loan, checked on the balance at the time the loan is taken.
Product limit: {{ productLienLimit }}%.
</p>
@if (lienPercentageError) {
<p class="racc-lien-error">{{ lienPercentageError }}</p>
}
</div>
<mat-form-field appearance="outline" class="racc-lien-field" subscriptSizing="dynamic">
<mat-label>Max loan (%)</mat-label>
<input
matInput
type="number"
min="1"
[max]="productLienLimit"
step="0.01"
[(ngModel)]="lienPercentage"
[ngModelOptions]="{ standalone: true }"
/>
<span matTextSuffix>%</span>
</mat-form-field>
</div>
}
<div class="racc-alert">
<div class="racc-alert__content">
<h4>Dormancy Alert</h4>
@@ -126,6 +126,15 @@ $gray-900: #202124;
}
}
.racc-lien-field {
width: 140px;
flex-shrink: 0;
}
.racc-lien-error {
color: #c62828;
}
.racc-alert--editable {
border-color: #bae6fd;
background: #f0f9ff;
@@ -6,8 +6,9 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core';
import { Component, Input, OnChanges, SimpleChanges, ViewChild, inject } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RecurringDepositsService } from '../../recurring-deposits.service';
import { NomineeFormComponent } from 'app/shared/nominee-form/nominee-form.component';
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
import { MatSlideToggle } from '@angular/material/slide-toggle';
@@ -36,7 +37,14 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
showNomineeForm = false;
nomineeError = '';
private recurringDepositsService = inject(RecurringDepositsService);
autoDebitFacility = false;
/**
* Lien Marked products only: how much of this RD may be given as loan, as % of its balance when the loan is taken.
* Starts at the product's percentage; may be lowered, never raised above it.
*/
lienPercentage: number | null = null;
private existingAccountApplied = false;
@@ -50,13 +58,44 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
if (Array.isArray(ea.nominees) && ea.nominees.length > 0) {
this.nominees = [...ea.nominees];
}
// The account response does not carry the deposit's own lien percentage; the lien endpoint does.
if (ea.id) {
this.recurringDepositsService.getRecurringDepositLiens(ea.id).subscribe((response: any) => {
this.lienPercentage = response?.lienPercentage ?? response?.productLienPercentage ?? null;
});
}
}
if (changes['recurringDepositsAccountProductTemplate']?.currentValue && !this.existingAccountApplied) {
const pt = changes['recurringDepositsAccountProductTemplate'].currentValue;
this.autoDebitFacility = !!pt.autoDebitFacility;
this.lienPercentage = pt.lienMarked ? (pt.lienPercentage ?? 100) : null;
}
}
get lienAllowed(): boolean {
return !!this.recurringDepositsAccountProductTemplate?.lienMarked;
}
/** The product's ceiling; a product with no percentage allows the whole balance. */
get productLienLimit(): number {
return Number(this.recurringDepositsAccountProductTemplate?.lienPercentage) || 100;
}
/** Why the entered percentage would be refused, or null when it is fine. */
get lienPercentageError(): string | null {
if (!this.lienAllowed) {
return null;
}
if (this.lienPercentage === null || this.lienPercentage === undefined || `${this.lienPercentage}` === '') {
return 'Enter how much of the RD balance can be given as loan.';
}
const value = Number(this.lienPercentage);
if (!(value > 0) || value > this.productLienLimit) {
return `Must be more than 0% and at most ${this.productLienLimit}% (product limit).`;
}
return null;
}
get totalShare(): number {
return this.nominees.reduce((sum, n) => sum + (+n.percentageShare || 0), 0);
}
@@ -112,7 +151,8 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
get currencyStepData() {
return {
autoDebitFacility: this.autoDebitFacility
autoDebitFacility: this.autoDebitFacility,
...(this.lienAllowed && this.lienPercentage != null ? { lienPercentage: Number(this.lienPercentage) } : {})
};
}
}
@@ -121,11 +121,30 @@
{{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }}
</span>
</div>
<div class="rad-field rad-info-row">
<span class="rad-info-label">Introducer Required</span>
<span class="rad-badge" [class.rad-badge--yes]="productTemplate.introducerRequired">
{{ productTemplate.introducerRequired ? 'Yes' : 'No' }}
</span>
</div>
</div>
</div>
<!-- The product requires an introducer: an existing customer, never the depositor themselves. -->
@if (productTemplate?.introducerRequired) {
<mifosx-introducer-search
[excludedClientIds]="primaryClientId ? [primaryClientId] : []"
[preselectedIntroducer]="
introducerId ? { clientId: introducerId, displayName: introducerName, cifNumber: introducerCif } : null
"
(introducerChange)="onIntroducerChange($event)"
></mifosx-introducer-search>
}
<!-- ══ Deposit Account Link ══════════════════════════════════════════ -->
@if (productTemplate?.shareLinkingEnabled) {
<!-- Also offered when the product allows Auto Debit: that is the account installments are collected from. -->
@if (productTemplate?.shareLinkingEnabled || productTemplate?.autoDebitFacility) {
<div class="dal-card">
<div class="dal-card__header">
<div class="dal-card__header-left">
@@ -24,6 +24,7 @@ import { Dates } from 'app/core/utils/dates';
import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
import { ProductsService } from 'app/products/products.service';
import { RecurringDepositsService } from '../../recurring-deposits.service';
import { ClientsService } from 'app/clients/clients.service';
import {
@@ -62,6 +63,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc
export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnChanges {
private formBuilder = inject(UntypedFormBuilder);
private recurringDepositsService = inject(RecurringDepositsService);
private productsService = inject(ProductsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private cdr = inject(ChangeDetectorRef);
@@ -117,6 +119,10 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
primaryClientName = '';
primaryClientCif = '';
primaryClientId: number | null = null;
/** Introducer - asked for only when the product has "Introducer Required" on. */
introducerId: number | null = null;
introducerName: string | null = null;
introducerCif: string | null = null;
primaryHolderShare = 50;
// ── Status of Account (RBI) ────────────────────────────────────────────
@@ -453,6 +459,14 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
}
}
this.productTemplate = response;
// The account template leaves out the product's installment limits; the calculator and the submit check
// read them from this template, so they are taken from the product itself.
if (response.minDepositAmount == null && response.maxDepositAmount == null) {
this.productsService.getRecurringDepositProduct(String(productId)).subscribe((product: any) => {
response.minDepositAmount = product?.minDepositAmount ?? null;
response.maxDepositAmount = product?.maxDepositAmount ?? null;
});
}
this.accountTypeOptions = this.extractOptions(response.accountTypeOptions, response.accountType);
this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment);
this.selectedAccountType = response.accountType ?? null;
@@ -463,7 +477,7 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
this.recurringDepositsAccountProductTemplate.emit(response);
this.fieldOfficerData = response.fieldOfficerOptions;
this.isProductSelected = true;
if (response.shareLinkingEnabled) {
if (response.shareLinkingEnabled || response.autoDebitFacility) {
this.loadClientAccounts();
}
this.recurringDepositAccountDetailsForm.patchValue({
@@ -610,6 +624,12 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
return this.dateUtils.formatDate(dateValue, 'M/d/yyyy') || '';
}
onIntroducerChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void {
this.introducerId = event?.clientId ?? null;
this.introducerName = event?.displayName ?? null;
this.introducerCif = event?.cifNumber ?? null;
}
onGuardianChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void {
this.guardianClientId = event?.clientId ?? null;
this.guardianName = event?.displayName ?? '';
@@ -795,6 +815,12 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
value.minorDateOfBirth = this.minorDob ? this.dateUtils.formatDate(this.minorDob, 'dd MMMM yyyy') : null;
}
if (this.productTemplate?.introducerRequired && this.introducerId) {
value.introducerId = this.introducerId;
value.introducerName = this.introducerName;
value.introducerCifNumber = this.introducerCif;
}
if (this.selectedLinkedAccount) {
value.linkedAccountId = this.selectedLinkedAccount.id;
value.linkedAccountType = this.selectedLinkedAccount.accountType;
@@ -93,7 +93,13 @@
<div class="fdrc-calc-field">
<label class="fdrc-calc-label">
Monthly Installment
<span class="fdrc-calc-optional">(optional — for maturity estimation)</span>
<span class="fdrc-calc-optional"
>(required@if (minInstallment > 0) {
— min ₹{{ minInstallment }}
}@if (maxInstallment > 0) {
, max ₹{{ maxInstallment }}
})</span
>
</label>
<div class="fdrc-amount-row">
<span class="fdrc-amount-prefix">₹</span>
@@ -257,6 +263,21 @@
</span>
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
</div>
@if (matchResult.interestCalculation) {
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Interest Type</span>
<span class="fdrc-maturity-row__value">
{{ matchResult.interestCalculation }}
@if (matchResult.interestCalculation === 'Compounding') {
({{ interestPeriodLabel(matchResult.compoundingPeriod) }})
}
</span>
</div>
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Interest Credited</span>
<span class="fdrc-maturity-row__value">{{ interestPeriodLabel(matchResult.postingPeriod) }}</span>
</div>
}
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
<span class="fdrc-maturity-row__label">Estimated Interest Earned</span>
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
@@ -267,6 +288,23 @@
>₹ {{ matchResult.maturityAmount | number: '1.2-2' }}</span
>
</div>
@if (matchResult.postingSchedule?.length) {
<details class="fdrc-credits">
<summary>Interest credits to the RD ({{ matchResult.postingSchedule.length }})</summary>
<table class="fdrc-credits__table">
<tr>
<th>Credited on</th>
<th class="text-right">Interest</th>
</tr>
@for (p of matchResult.postingSchedule; track p.date) {
<tr>
<td>{{ p.date }}</td>
<td class="text-right">₹ {{ p.interest | number: '1.2-2' }}</td>
</tr>
}
</table>
</details>
}
</div>
</div>
}
@@ -1127,3 +1127,31 @@ $red-light: #fee2e2;
font-size: 14px;
}
}
.fdrc-credits {
margin-top: 8px;
font-size: 12px;
summary {
cursor: pointer;
font-weight: 600;
color: #2563eb;
}
&__table {
width: 100%;
margin-top: 6px;
border-collapse: collapse;
th,
td {
padding: 3px 6px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
}
.text-right {
text-align: right;
}
}
}
@@ -42,6 +42,7 @@ import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { YesnoPipe } from '../../../../pipes/yesno.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormsModule } from '@angular/forms';
import { buildRdChart, tenureInMonths } from 'app/products/recurring-deposit-products/rd-product-chart';
export type RdPeriodUnit = 'Days' | 'Months' | 'Years';
@@ -53,6 +54,12 @@ export interface SlabMatchResult {
interestEarned?: number;
tenureDays?: number;
daysInYear?: number;
/** From the product, as the server calculated it: Simple / Compounding, and the period names. */
interestCalculation?: string;
compoundingPeriod?: string;
postingPeriod?: string;
/** Each interest credit the deposit will receive, if every installment is paid on time. */
postingSchedule?: { date: string; interest: number }[];
}
/**
@@ -123,20 +130,62 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
return (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive);
}
/**
* The product's own chart - the one the server prices the RD from - with each slab's senior-citizen rate taken
* from the CBS base chart it was built from. Falls back to the base chart only when the product has no chart.
*/
get interestRateChartData(): any[] {
if (this.activeBase?.slabs?.length) {
return this.normalizeCbsSlabs(this.activeBase.slabs);
}
const cbsSlabs: any[] = this.activeBase?.slabs || [];
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
if (!template) return [];
return (
template.accountChart?.chartSlabs ??
template.activeChart?.chartSlabs ??
template.interestRateCharts?.[0]?.chartSlabs ??
template.interestRateChart?.chartSlabs ??
template.chartSlabs ??
[]
);
const productSlabs: any[] =
template?.accountChart?.chartSlabs ??
template?.activeChart?.chartSlabs ??
template?.interestRateCharts?.[0]?.chartSlabs ??
template?.interestRateChart?.chartSlabs ??
template?.chartSlabs ??
[];
if (this.chartCache?.cbsSlabs !== cbsSlabs || this.chartCache?.productSlabs !== productSlabs) {
this.chartCache = { cbsSlabs, productSlabs, slabs: this.mergeChart(cbsSlabs, productSlabs) };
}
return this.chartCache.slabs;
}
private chartCache: { cbsSlabs: any[]; productSlabs: any[]; slabs: any[] } | null = null;
/** The product's minimum deposit term in months - the server refuses anything shorter. Null when it sets none. */
/** The product's installment limits (0 = no limit). */
get minInstallment(): number {
return Number(this.recurringDepositsAccountProductTemplate?.minDepositAmount) || 0;
}
get maxInstallment(): number {
return Number(this.recurringDepositsAccountProductTemplate?.maxDepositAmount) || 0;
}
get productMinTenureMonths(): number | null {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return tenureInMonths(template?.minDepositTerm, template?.minDepositTermType?.id);
}
private mergeChart(cbsSlabs: any[], productSlabs: any[]): any[] {
const baseSlabs = this.normalizeCbsSlabs(buildRdChart(cbsSlabs, [], null).slabs);
if (!productSlabs.length) {
return baseSlabs;
}
// Products made before RD charts moved to months still hold the base chart's own year slabs
const candidates = [...baseSlabs, ...this.normalizeCbsSlabs(cbsSlabs)];
return productSlabs.map((slab: any) => {
if (slab.seniorCitizenRate != null) {
return slab;
}
const base = candidates.find(
(candidate: any) =>
candidate.fromPeriod === slab.fromPeriod &&
(candidate.toPeriod ?? null) === (slab.toPeriod ?? null) &&
this.periodLabel(candidate) === this.periodLabel(slab)
);
return base?.seniorCitizenRate != null ? { ...slab, seniorCitizenRate: base.seniorCitizenRate } : slab;
});
}
private normalizeCbsSlabs(slabs: any[]): any[] {
@@ -146,6 +195,10 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
}));
}
private periodLabel(slab: any): string {
return String(slab.periodType?.value ?? slab.periodTypeLabel ?? '').toLowerCase();
}
chartSlabsDisplayedColumns: string[] = [
'period',
'amountRange',
@@ -253,6 +306,28 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
return period; // days
}
/**
* Whether the tenure falls in the slab, counted the way the server counts it: in whole units of the slab's own
* period type, so a 30-month RD is "2 years" against a year slab and lands in "1-2 Years" rather than between
* slabs. Day and week slabs keep the day comparison.
*/
private isTenureInSlab(slab: any, tenureMonths: number, tenureDays: number): boolean {
const label = this.periodLabel(slab) || 'days';
let tenure: number;
let from = slab.fromPeriod ?? 0;
let to = slab.toPeriod ?? Infinity;
if (label.includes('month')) {
tenure = tenureMonths;
} else if (label.includes('year')) {
tenure = Math.floor(tenureMonths / 12);
} else {
tenure = tenureDays;
from = this.slabPeriodToDays(from, label);
to = to === Infinity ? Infinity : this.slabPeriodToDays(to, label);
}
return tenure >= from && tenure <= to;
}
calculateRate() {
this.validationWarning = null;
this.matchResult = null;
@@ -263,6 +338,26 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
}
const enteredDays = this.toDays(this.tenureValue, this.tenurePeriodUnit);
const enteredMonths = this.tenurePeriodUnit === 'Years' ? this.tenureValue * 12 : this.tenureValue;
const minimumMonths = this.productMinTenureMonths;
if (minimumMonths != null && enteredMonths < minimumMonths) {
this.validationWarning = `Minimum RD tenure for this product is ${minimumMonths} months.`;
return;
}
// The installment must be within the product's limits - the same check the submit and the server make, shown
// here so a wrong amount is caught before the rest of the form is filled.
if (!this.principalAmount || this.principalAmount <= 0) {
this.validationWarning = 'Please enter the monthly installment amount.';
return;
}
if (this.minInstallment > 0 && this.principalAmount < this.minInstallment) {
this.validationWarning = `Minimum monthly installment for this product is ₹${this.minInstallment}.`;
return;
}
if (this.maxInstallment > 0 && this.principalAmount > this.maxInstallment) {
this.validationWarning = `Maximum monthly installment for this product is ₹${this.maxInstallment}.`;
return;
}
const slabs: any[] = this.interestRateChartData || [];
if (!slabs.length) {
@@ -275,11 +370,7 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
for (let i = 0; i < slabs.length; i++) {
const slab = slabs[i];
const periodTypeValue = slab.periodType?.value || 'Days';
const fromDays = this.slabPeriodToDays(slab.fromPeriod ?? 0, periodTypeValue);
const toDays = slab.toPeriod != null ? this.slabPeriodToDays(slab.toPeriod, periodTypeValue) : Infinity;
if (enteredDays >= fromDays && enteredDays <= toDays) {
if (this.isTenureInSlab(slab, enteredMonths, enteredDays)) {
matchedSlab = slab;
matchedIndex = i;
break;
@@ -329,10 +420,20 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
tenureInMonths: tenureInMonths,
interestCompoundingPeriodInMonths: compoundingMonths
};
// With the product named, the server calculates with the product's own interest settings (simple/compounding,
// compounding and posting periods, days in year) - the same figure the opened account will carry.
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const productId = template?.depositProductId ?? template?.productId;
if (productId) {
calculationData.productId = productId;
}
if (this.submittedOnDate) {
const date = new Date(this.submittedOnDate);
calculationData.submittedOnDate = date.toISOString().split('T')[0];
// The local calendar date - toISOString() is UTC, which in IST turns midnight of the 9th into the 8th and
// starts the projection a day early (a different maturity amount from the one the account gets).
const pad = (n: number) => String(n).padStart(2, '0');
calculationData.submittedOnDate = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
calculationData.dateFormat = 'yyyy-MM-dd'; // Standard ISO for API
calculationData.locale = 'en';
}
@@ -341,6 +442,10 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
result.maturityAmount = apiResult.maturityAmount;
result.interestEarned = apiResult.totalInterest;
result.daysInYear = apiResult.daysInYear;
result.interestCalculation = apiResult.interestCalculation;
result.compoundingPeriod = apiResult.compoundingPeriod;
result.postingPeriod = apiResult.postingPeriod;
result.postingSchedule = apiResult.postingSchedule;
this.matchResult = { ...result };
this.emitTenureSelection();
});
@@ -350,6 +455,18 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
}
}
/** "QUATERLY" / "BIANNUAL" / "MONTHLY" (server enum names) as a person would say them. */
interestPeriodLabel(name?: string): string {
const labels: Record<string, string> = {
DAILY: 'Daily',
MONTHLY: 'Monthly',
QUATERLY: 'Quarterly',
BIANNUAL: 'Half-yearly',
ANNUAL: 'Yearly'
};
return name ? (labels[name] ?? name) : '';
}
private emitTenureSelection() {
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
if (frequencyId !== null && this.matchResult) {
@@ -441,76 +558,54 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
// ── Header Getters ──────────────────────────────────────────────────────
get chartName(): string {
if (this.activeBase) {
return this.activeBase.versionLabel || 'CBS Base Interest Rate';
}
/**
* The RD product's own chart - the one the deposit is actually priced on. The CBS base chart (an FD master the
* product's chart was built from) is shown only when the product has no chart of its own.
*/
private get productChart(): any {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.name ||
template?.activeChart?.name ||
template?.interestRateCharts?.[0]?.name ||
template?.interestRateChart?.name ||
template?.name ||
''
template?.accountChart ||
template?.activeChart ||
template?.interestRateCharts?.[0] ||
template?.interestRateChart ||
null
);
}
get chartName(): string {
if (this.productChart) {
return this.productChart.name || '';
}
return this.activeBase?.versionLabel || 'CBS Base Interest Rate';
}
get chartFromDate(): any {
if (this.activeBase) {
return this.activeBase.effectiveFromDate;
if (this.productChart) {
return this.productChart.fromDate;
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.fromDate ||
template?.activeChart?.fromDate ||
template?.interestRateCharts?.[0]?.fromDate ||
template?.interestRateChart?.fromDate ||
template?.fromDate
);
return this.activeBase?.effectiveFromDate;
}
get chartEndDate(): any {
if (this.activeBase) {
return null;
if (this.productChart) {
return this.productChart.endDate;
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.endDate ||
template?.activeChart?.endDate ||
template?.interestRateCharts?.[0]?.endDate ||
template?.interestRateChart?.endDate ||
template?.endDate
);
return null;
}
get chartDescription(): string {
if (this.activeBase) {
return this.activeBase.remarks || '';
if (this.productChart) {
return this.productChart.description || '';
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.description ||
template?.activeChart?.description ||
template?.interestRateCharts?.[0]?.description ||
template?.interestRateChart?.description ||
template?.description ||
''
);
return this.activeBase?.remarks || '';
}
get isActive(): boolean {
if (this.activeBase) {
return this.activeBase.isActive;
if (this.productChart) {
return this.productChart.isActive ?? true;
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.isActive ??
template?.activeChart?.isActive ??
template?.interestRateCharts?.[0]?.isActive ??
template?.interestRateChart?.isActive ??
true
);
return this.activeBase?.isActive ?? true;
}
get isPrimaryGroupingByAmount(): boolean {
@@ -84,25 +84,17 @@
</div>
}
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@if (closureSettingsForm.value.preClosurePenalApplicable) {
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Penal Interest' | translate }} (%)</mat-label>
<input type="number" matInput formControlName="preClosurePenalInterest" min="0" step="0.01" />
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Period' | translate }}</mat-label>
<mat-select formControlName="preClosurePenalInterestOnTypeId">
@for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) {
<mat-option [value]="preClosurePenalInterestOnType.id">
{{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
<!-- The premature-closure penalty is the product's, shown here and never changed per account. -->
<div class="flex-98 margin-v rdmt-penal">
<div class="rdmt-penal__title">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }} <span class="rdmt-penal__tag">From Product</span></div>
@if (closureSettingsForm.value.preClosurePenalApplicable) {
<div class="rdmt-penal__row">
<span>Penal Interest: <strong>{{ closureSettingsForm.value.preClosurePenalInterest }}%</strong></span>
<span>Period: <strong>{{ penalPeriodLabel }}</strong></span>
</div>
<div class="rdmt-penal__hint">On premature closure the rate is reduced by this much.</div>
} @else {
<div class="rdmt-penal__row"><strong>No</strong> - no penal interest is charged on premature closure of this product.</div>
}
</div>
</form>
@@ -150,3 +150,37 @@
}
}
}
.rdmt-penal {
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
font-size: 13px;
&__title {
font-weight: 600;
margin-bottom: 6px;
}
&__tag {
margin-left: 6px;
padding: 1px 6px;
border-radius: 8px;
background: #e0f2fe;
color: #0369a1;
font-size: 10px;
font-weight: 700;
}
&__row {
display: flex;
gap: 24px;
}
&__hint {
margin-top: 4px;
font-size: 11px;
color: #64748b;
}
}
@@ -82,7 +82,7 @@ export class RecurringDepositsAccountMaturityTermsStepComponent implements OnCha
);
this.savingsAccountsData = template.savingsAccounts ?? [];
this.preClosurePenalInterestOnTypeData = template.preClosurePenalInterestOnTypeOptions ?? [];
if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) {
{
this.closureSettingsForm.patchValue({
preClosurePenalApplicable: template.preClosurePenalApplicable ?? false,
preClosurePenalInterest: template.preClosurePenalInterest ?? '',
@@ -103,9 +103,21 @@ export class RecurringDepositsAccountMaturityTermsStepComponent implements OnCha
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
}
/** Chosen maturity/closure settings, ready to merge into the submit payload. */
/** "Whole Term" / "Till Premature Withdrawal", as the product has it. */
get penalPeriodLabel(): string {
const id = this.closureSettingsForm.value.preClosurePenalInterestOnTypeId;
return this.preClosurePenalInterestOnTypeData.find((option: any) => option.id === id)?.value ?? '-';
}
/**
* Chosen maturity/closure settings, ready to merge into the submit payload. The premature-closure penalty is left
* out: it is the product's, which the submit already carries and the server applies regardless.
*/
get closureSettings() {
const settings = this.closureSettingsForm.getRawValue();
delete settings.preClosurePenalApplicable;
delete settings.preClosurePenalInterest;
delete settings.preClosurePenalInterestOnTypeId;
for (const key in settings) {
if (settings[key] === '') {
delete settings[key];
@@ -878,12 +878,12 @@
}
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalApplicable | yesNo }}</span>
<span class="preview-value">{{ productPenal.applicable | yesNo }}</span>
</div>
@if (closureSettings?.preClosurePenalApplicable) {
@if (productPenal.applicable) {
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Penal Interest' | translate }} (%)</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalInterest }}%</span>
<span class="preview-value">{{ productPenal.interest }}%</span>
</div>
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Period' | translate }}</span>
@@ -218,10 +218,20 @@ export class RecurringDepositsAccountPreviewStepComponent implements OnChanges {
return match ? `(${match.accountNo}) ${match.savingsProductName}` : '—';
}
/** The premature-closure penalty is the product's (the account cannot change it), so it is shown from the product. */
get productPenal(): { applicable: boolean; interest: any; typeId: any } {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return {
applicable: !!template?.preClosurePenalApplicable,
interest: template?.preClosurePenalInterest,
typeId: template?.preClosurePenalInterestOnType?.id
};
}
get closurePenalInterestOnTypeLabel(): string {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const options = template?.preClosurePenalInterestOnTypeOptions ?? [];
const match = options.find((o: any) => o.id === this.closureSettings?.preClosurePenalInterestOnTypeId);
const match = options.find((o: any) => o.id === this.productPenal.typeId);
return match?.value ?? '—';
}
@@ -200,6 +200,27 @@
}}
</span>
</div>
@if (lienLimit) {
<div class="nx-row">
<span class="nx-row__label">Loan against RD ({{ lienLimit.percentage }}% of balance)</span>
<span class="nx-row__value nx-row__value--amount">
up to {{ lienLimit.maxLoanAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}, available
{{ lienLimit.availableForLoan | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (totalLienAmount > 0) {
<div class="nx-row">
<span class="nx-row__label">Lien Marked (loan security)</span>
<span class="nx-row__value nx-row__value--amount">
{{ totalLienAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
-
@for (lien of liens; track lien.loanId; let last = $last) {
Loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
</span>
</div>
}
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Recurring Deposit Amount' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
@@ -15,6 +15,7 @@ import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RecurringDepositsService } from '../../recurring-deposits.service';
@Component({
selector: 'mifosx-general-tab',
@@ -34,14 +35,22 @@ export class GeneralTabComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private recurringDepositsService = inject(RecurringDepositsService);
recurringDepositsAccountData: any;
isprematureAllowed = false;
entityType: string;
currency: Currency;
/** Loans this deposit secures; empty when it is free of lien. */
liens: any[] = [];
totalLienAmount = 0;
/** Loan limit today (Lien Marked products only): % that applies, what it comes to, and what is left. */
lienLimit: { percentage: number; maxLoanAmount: number; availableForLoan: number } | null = null;
constructor() {
this.route.parent.data.subscribe((data: { recurringDepositsAccountData: any }) => {
this.recurringDepositsAccountData = data.recurringDepositsAccountData;
this.loadLiens(this.recurringDepositsAccountData?.id);
this.currency = this.recurringDepositsAccountData.currency;
this.isprematureAllowed = data.recurringDepositsAccountData.maturityDate != null;
if (this.router.url.includes('clients')) {
@@ -53,4 +62,24 @@ export class GeneralTabComponent {
}
});
}
private loadLiens(accountId: any) {
this.liens = [];
this.totalLienAmount = 0;
this.lienLimit = null;
if (!accountId) {
return;
}
this.recurringDepositsService.getRecurringDepositLiens(accountId).subscribe((response: any) => {
this.liens = response?.liens ?? [];
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
this.lienLimit = response?.lienAllowed
? {
percentage: Number(response.effectiveLienPercentage) || 0,
maxLoanAmount: Number(response.maxLoanAmount) || 0,
availableForLoan: Number(response.availableForLoan) || 0
}
: null;
});
}
}
@@ -82,6 +82,20 @@
<span class="rd-kpi__label">{{ 'labels.inputs.Deposit Period' | translate }}</span>
<span class="rd-kpi__value">{{ recurringDepositsAccountData.depositPeriod | number }}</span>
</div>
<!-- Interest credited to the deposit so far - by its product's posting period (monthly, quarterly...), plus the
remainder on the maturity date. -->
<div class="rd-kpi rd-kpi--interest">
<span class="rd-kpi__label">Interest Posted</span>
<span class="rd-kpi__value">
{{
(recurringDepositsAccountData.summary.totalInterestPosted || 0)
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
@if (recurringDepositsAccountData.interestPostingPeriodType?.value) {
<span class="rd-kpi__sub">Credited {{ recurringDepositsAccountData.interestPostingPeriodType.value }}</span>
}
</div>
</div>
}
@@ -212,6 +212,17 @@ $border: #e2e8f0;
background: rgb(139 92 246 / 22%);
border-color: rgb(139 92 246 / 35%);
}
&--interest {
background: rgb(16 185 129 / 22%);
border-color: rgb(16 185 129 / 35%);
}
&__sub {
margin-top: 1px;
font-size: 9.5px;
color: rgb(255 255 255 / 75%);
}
}
/* Status pill */
@@ -40,6 +40,10 @@ import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
import { ProductsService } from 'app/products/products.service';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { AlertService } from 'app/core/alert/alert.service';
/**
* RecurringDeposits Account View Component
@@ -79,6 +83,8 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private authenticationService = inject(AuthenticationService);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
dialog = inject(MatDialog);
private translateService = inject(TranslateService);
@@ -105,6 +111,7 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
'Enable Withhold Tax': 'interest',
'Disable Withhold Tax': 'interest',
'Premature Close': 'danger',
'Extend Tenure': 'admin',
'Undo Activation': 'admin',
Close: 'danger',
'Modify Application': 'admin',
@@ -128,6 +135,7 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
'Enable Withhold Tax': 'percentage',
'Disable Withhold Tax': 'percentage',
'Premature Close': 'times-circle',
'Extend Tenure': 'calendar-alt',
'Undo Activation': 'undo',
Close: 'times-circle',
'Modify Application': 'edit',
@@ -184,6 +192,30 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
ngOnInit() {
this.setConditionalButtons();
this.applyProductRules();
}
/**
* Buttons the deposit's product decides: no Premature Close unless the product has "Premature Withdrawal option" on,
* and an Extend Tenure action when it has "Tenure Flexibility" on. The server enforces both as well.
*/
private applyProductRules() {
const productId = this.recurringDepositsAccountData.depositProductId;
if (!productId) {
return;
}
this.productsService.getRecurringDepositProduct(String(productId)).subscribe((product: any) => {
if (!product?.prematureWithdrawalOption) {
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter((btn) => btn.name !== 'Premature Close');
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter((opt) => opt.name !== 'Premature Close');
}
if (product?.tenureFlexibility && this.recurringDepositsAccountData.status?.value === 'Active') {
this.buttonConfig.addOption({
name: 'Extend Tenure',
taskPermissionName: 'EXTENDTENURE_RECURRINGDEPOSITACCOUNT'
});
}
});
}
/**
@@ -312,9 +344,61 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
case 'Terminate':
this.terminateRecurringDepositsAccount();
break;
case 'Extend Tenure':
this.extendTenure();
break;
}
}
/**
* Lengthens the deposit's tenure (product has Tenure Flexibility on). New installments are added after the current
* last one, so Auto Debit keeps collecting through the added months; the rate becomes the chart's rate for the new
* tenure and the maturity date and amount are worked out again.
*/
private extendTenure() {
const current = Number(this.recurringDepositsAccountData.depositPeriod) || 0;
const unit = this.recurringDepositsAccountData.depositPeriodFrequency?.value || 'Months';
const data = {
title: 'Extend Tenure',
layout: { addButtonText: 'Extend' },
formfields: [
new InputBase({
controlName: 'depositPeriod',
label: `New tenure in ${unit} (now ${current})`,
value: current + 1,
type: 'number',
required: true,
min: current + 1,
order: 1
})
]
};
this.dialog
.open(FormDialogComponent, { data })
.afterClosed()
.subscribe((response: any) => {
const newPeriod = Number(response?.data?.value?.depositPeriod);
if (!response?.data) {
return;
}
if (!newPeriod || newPeriod <= current) {
this.alertService.alert({ type: 'Extend Tenure', message: `The new tenure must be more than ${current} ${unit}.` });
return;
}
this.recurringDepositsService
.executeRecurringDepositsAccountCommand(this.recurringDepositsAccountData.id, 'extendTenure', {
depositPeriod: newPeriod,
locale: this.settingsService.language.code
})
.subscribe((result: any) => {
if (result?.commandId && !result?.changes) {
this.alertService.alert({ type: 'Extend Tenure', message: 'Tenure extension submitted for approval.' });
}
this.reload();
});
});
}
/**
* Deletes Recurring Deposits Account.
*/
@@ -36,12 +36,9 @@ export class RecurringDepositsButtonsConfiguration {
setButtons(status: string) {
switch (status) {
case 'Active':
// No Deposit button: a direct deposit here skips the manager's approval. RD installments come in
// through the cashier's Receipt (maker-checker) or Auto Debit.
this.buttonsArray = [
{
name: 'Deposit',
icon: 'fa fa-arrow-up',
taskPermissionName: 'DEPOSIT_RECURRINGDEPOSITACCOUNT'
},
{
name: 'Withdrawal',
icon: 'fa fa-arrow-down',
@@ -103,12 +100,9 @@ export class RecurringDepositsButtonsConfiguration {
name: 'Calculate Interest',
icon: 'fa fa-calculator',
taskPermissionName: 'CALCULATEINTEREST_RECURRINGDEPOSITACCOUNT'
},
{
name: 'Post Interest',
icon: 'fa fa-money',
taskPermissionName: 'POSTINTEREST_RECURRINGDEPOSITACCOUNT'
}
// No Post Interest: the term's interest is credited by the server on the maturity date, and again only at
// closure for any days after it.
];
break;
case 'Rejected':
@@ -31,6 +31,14 @@ export class RecurringDepositsService {
return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}`, { params: httpParams });
}
/**
* Loans this recurring deposit currently secures (lien), with the amount each one holds.
* Returns { totalLienAmount, liens: [{ loanId, loanAccountNo, loanStatus, amount }] }.
*/
getRecurringDepositLiens(recurringDepositAccountId: any): Observable<any> {
return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}/liens`);
}
/**
* @param clientId Client Id
* @param clientName Client Name
@@ -176,9 +176,8 @@
<!-- ===================== 01 PURCHASE ENTRY ===================== -->
@if (activeTab === 'purchase') {
<div class="pg-card">
<!-- 1. Deal details — the only fields the dealer has to fill -->
<div class="pos-section-label">
Purchase Entry — {{ instrumentLabel(purchaseForm.bondType) }} (fields marked * are mandatory)
Investment - Govt.Security/Bond Entry ({{ instrumentLabel(purchaseForm.bondType) }})
</div>
<div class="inv-form-grid">
<label class="inv-form-field">
@@ -189,6 +188,17 @@
}
</select>
</label>
<label class="inv-form-field"
><span>Deal No</span><input type="text" [(ngModel)]="purchaseForm.dealNo" readonly
/></label>
<label class="inv-form-field"
><span>* Deal Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.dealDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>* Deal Time (24 hr, HH:mm:ss)</span
><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss"
/></label>
<label class="inv-form-field"><span>* ISIN</span><input type="text" [(ngModel)]="purchaseForm.isin" /></label>
<label class="inv-form-field">
<span>* Script Code</span>
<select [(ngModel)]="purchaseForm.scriptCode" (ngModelChange)="onScriptChange()">
@@ -198,50 +208,167 @@
}
</select>
</label>
<label class="inv-form-field">
<span>* Party Code</span>
<select [(ngModel)]="purchaseForm.partyCode">
<option value="">-- Select --</option>
@for (p of activePartyOptions; track p.partyCode) {
<option [value]="p.partyCode">{{ p.partyCode }} — {{ p.partyName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>* Deal Date</span
><mifosx-date-ddmmyyyy
[(ngModel)]="purchaseForm.dealDate"
(ngModelChange)="onDealDateChange()"
></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>* Deal Time (HH:mm:ss)</span
><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss"
><span>* Cert No</span><input type="text" [(ngModel)]="purchaseForm.certNo"
/></label>
<label class="inv-form-field">
<span>* Settlement Type</span>
<span>Settlement Type</span>
<select [(ngModel)]="purchaseForm.settlementType">
<option value="T+0">T+0</option>
<option value="T+1">T+1</option>
<option value="T+2">T+2</option>
</select>
</label>
<label class="inv-form-field"
><span>Settlement Dt</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.settlementDt"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>* Party Code</span>
<select [(ngModel)]="purchaseForm.partyCode">
<option value="">-- Select --</option>
@for (p of partyOptions; track p.partyCode) {
<option [value]="p.partyName">{{ p.partyName }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Sub Type</span>
<select [(ngModel)]="purchaseForm.subType">
@for (s of subTypeOptions; track s.subTypeCode + s.subTypeName) {
<option [value]="s.subTypeName">{{ s.subTypeName }}</option>
}
</select>
</label>
@if (isMutualFund) {
<label class="inv-form-field">
<span>* Product Name</span>
<select [(ngModel)]="purchaseForm.productName">
<option value="">-- Select --</option>
@for (p of mfProductOptions; track p.productCode) {
<option [value]="p.productName">{{ p.productName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>* Consider Amt (₹)</span><input type="number" [(ngModel)]="purchaseForm.considerAmt" min="0"
><span>Holding Days</span><input type="number" [(ngModel)]="purchaseForm.holdingDays" min="0"
/></label>
<label class="inv-form-field"
><span>* NAV</span><input type="number" [(ngModel)]="purchaseForm.nav" min="0" step="0.0001"
><span>* Consider Amt</span><input type="number" [(ngModel)]="purchaseForm.considerAmt" min="0"
/></label>
<label class="inv-form-field"
><span>Trade Amt</span><input type="number" [(ngModel)]="purchaseForm.tradeAmt" min="0"
/></label>
<label class="inv-form-field"
><span>NAV</span><input type="number" [(ngModel)]="purchaseForm.nav" min="0" step="0.0001"
/></label>
<label class="inv-form-field"
><span>Stamp Duty</span><input type="number" [(ngModel)]="purchaseForm.stampDuty" min="0"
/></label>
<label class="inv-form-field"
><span>StampDuty A/c</span><input type="text" [(ngModel)]="purchaseForm.stampDutyAc"
/></label>
} @else {
<label class="inv-form-field"
><span>* Face Value (₹)</span><input type="number" [(ngModel)]="purchaseForm.faceValue" min="0"
><span>Face Value</span><input type="number" [(ngModel)]="purchaseForm.faceValue" min="0"
/></label>
<label class="inv-form-field"
><span>* Purchase Rate (per ₹100)</span
><span>Purchase Rate (per 100)</span
><input type="number" [(ngModel)]="purchaseForm.purchaseRate" min="0" step="0.0001"
/></label>
<label class="inv-form-field"
><span>Unit Val</span><input type="text" [value]="purchaseUnitVal" readonly
/></label>
<label class="inv-form-field"
><span>Book Value</span><input type="text" [value]="purchaseBookValue | number: '1.2-2'" readonly
/></label>
<label class="inv-form-field"
><span>Premium Amt.</span><input type="number" [(ngModel)]="purchaseForm.premiumAmt"
/></label>
}
<label class="inv-form-field"
><span>* No Unit</span><input type="text" [value]="purchaseUnits" readonly
/></label>
<label class="inv-form-field"
><span>* CR. To A/c</span><input type="text" [(ngModel)]="purchaseForm.crToAc"
/></label>
<label class="inv-form-field"
><span>TBill GL.A/C.</span><input type="text" [(ngModel)]="purchaseForm.tbillGlAc"
/></label>
<label class="inv-form-field"
><span>Brokerage</span><input type="number" [(ngModel)]="purchaseForm.brokerage" min="0"
/></label>
<label class="inv-form-field">
<span>Broker Nm.</span>
<select [(ngModel)]="purchaseForm.brokerName">
<option value="">-- Select --</option>
@for (b of brokerOptions; track b.brokerCode) {
<option [value]="b.brokerCode">{{ b.brokerCode }} — {{ b.brokerName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Trade No.</span><input type="text" [(ngModel)]="purchaseForm.tradeNo"
/></label>
<label class="inv-form-field"
><span>Order No.</span><input type="text" [(ngModel)]="purchaseForm.orderNo"
/></label>
<label class="inv-form-field">
<span>Recomm. Authority</span>
<select [(ngModel)]="purchaseForm.recommAuthority">
<option value="">-- Select --</option>
@for (a of authorityOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Sanction Authority</span>
<select [(ngModel)]="purchaseForm.sanctionAuthority">
<option value="">-- Select --</option>
@for (a of authorityOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>* Issue Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.issueDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Period(Year)</span><input type="number" [(ngModel)]="purchaseForm.periodYear" min="0"
/></label>
<label class="inv-form-field"
><span>Period(Month)</span><input type="number" [(ngModel)]="purchaseForm.periodMonth" min="0" max="11"
/></label>
<label class="inv-form-field"
><span>Period(Days)</span><input type="number" [(ngModel)]="purchaseForm.periodDays" min="0"
/></label>
<label class="inv-form-field"
><span>Desc Inv</span><input type="text" [(ngModel)]="purchaseForm.descInv"
/></label>
<label class="inv-form-field"
><span>Folio No</span><input type="text" [(ngModel)]="purchaseForm.folioNo"
/></label>
<label class="inv-form-field"
><span>Call Option</span><input type="text" [(ngModel)]="purchaseForm.callOption"
/></label>
<label class="inv-form-field"
><span>Meet Dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.meetDt"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Reso No</span><input type="text" [(ngModel)]="purchaseForm.resoNo"
/></label>
<label class="inv-form-field"
><span>From Dist</span><input type="text" [(ngModel)]="purchaseForm.fromDist"
/></label>
<label class="inv-form-field"
><span>To Dist</span><input type="text" [(ngModel)]="purchaseForm.toDist"
/></label>
<label class="inv-form-field"
><span>Redum/Mat Dt</span
><input type="text" [(ngModel)]="purchaseForm.redumMatDt" placeholder="DD-MM-YYYY (blank = use period)"
/></label>
<label class="inv-form-field">
<span>* Category</span>
<select [(ngModel)]="purchaseForm.category">
@@ -253,258 +380,32 @@
</label>
@if (!isMutualFund) {
<label class="inv-form-field"
><span>* Last Interest Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.lastIntDt"></mifosx-date-ddmmyyyy
></label>
}
<label class="inv-form-field">
<span>* CR. To A/c (Paid From)</span>
<select [(ngModel)]="purchaseForm.crToAc">
@for (g of settlementBankOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Stamp Duty (₹)</span><input type="number" [(ngModel)]="purchaseForm.stampDuty" min="0"
/></label>
<label class="inv-form-field"
><span>Brokerage (₹)</span><input type="number" [(ngModel)]="purchaseForm.brokerage" min="0"
/></label>
<label class="inv-form-field"
><span>Other Charges (₹)</span><input type="number" [(ngModel)]="purchaseForm.otherCharges" min="0"
/></label>
</div>
@if (purchaseMarketRate) {
<p class="pg-sub" style="padding: 0 16px 8px">
Market rate (Daily Script Master) as on {{ purchaseMarketRate.rateDate }}: Close
{{ purchaseMarketRate.closeRate | number: '1.2-4' }}
@if (purchaseMarketRate.lowRate && purchaseMarketRate.highRate) {
(Low {{ purchaseMarketRate.lowRate | number: '1.2-4' }} – High
{{ purchaseMarketRate.highRate | number: '1.2-4' }})
}
@if (purchaseMarketRate.rateDate !== purchaseForm.dealDate) {
— no rate entered for the deal date, the latest earlier rate is used.
}
</p>
@if (purchaseRateOutOfRange) {
<p class="pg-sub" style="padding: 0 16px 8px; color: #b42318; font-weight: 600">
Warning: the rate entered is outside the day's market range.
</p>
}
} @else if (purchaseMarketRate === null) {
<p class="pg-sub" style="padding: 0 16px 8px; color: #b42318">
No rate in the Daily Script Master for this script — enter the day's rate there first.
</p>
}
<!-- 2. Calculated — filled by the system, read-only -->
<div class="pos-section-label">Calculated by System</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Deal No</span><input type="text" [value]="purchaseForm.dealNo" readonly
/></label>
<label class="inv-form-field"
><span>Cert No</span><input type="text" [value]="purchaseForm.certNo" readonly
/></label>
<label class="inv-form-field"
><span>Settlement Date</span><input type="text" [value]="purchaseSettlementDate" readonly
/></label>
<label class="inv-form-field"
><span>ISIN</span><input type="text" [value]="purchaseForm.isin || '—'" readonly
/></label>
<label class="inv-form-field"
><span>Sub Type</span><input type="text" [value]="purchaseForm.subType || '—'" readonly
/></label>
@if (!isMutualFund) {
<label class="inv-form-field"
><span>Int Rate %</span><input type="text" [value]="purchaseForm.intRate ?? '—'" readonly
><span>Int Rate</span><input type="number" [(ngModel)]="purchaseForm.intRate" min="0" step="0.01"
/></label>
<label class="inv-form-field"
><span>Issue Date</span><input type="text" [value]="purchaseForm.issueDate || '—'" readonly
><span>YTM (computed)</span><input type="text" [value]="purchaseYtm" readonly
/></label>
<label class="inv-form-field"
><span>Maturity Date</span><input type="text" [value]="purchaseMaturityDate || '—'" readonly
/></label>
<label class="inv-form-field"
><span>Period (Y / M / D)</span
><input
type="text"
[value]="purchaseForm.periodYear + ' / ' + purchaseForm.periodMonth + ' / ' + purchaseForm.periodDays"
readonly
/></label>
}
<label class="inv-form-field"
><span>No. of Units</span><input type="text" [value]="purchaseUnits | number: '1.0-4'" readonly
/></label>
<label class="inv-form-field"
><span>Book Value</span><input type="text" [value]="'₹' + (purchaseBookValue | number: '1.2-2')" readonly
/></label>
@if (!isMutualFund) {
<label class="inv-form-field"
><span>Premium (+) / Discount (−)</span
><input type="text" [value]="'₹' + (purchasePremiumDiscount | number: '1.0-0')" readonly
/></label>
<label class="inv-form-field"
><span>Accrued Interest ({{ purchaseAccruedDays }} days)</span
><input type="text" [value]="'₹' + (purchaseAccruedInterest | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"><span>YTM %</span><input type="text" [value]="purchaseYtm" readonly /></label>
}
<label class="inv-form-field"
><span>Charges (expense)</span
><input type="text" [value]="'₹' + (purchaseTotalCharges | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Total Settlement Amount</span
><input type="text" [value]="'₹' + (purchaseSettlementAmount | number: '1.2-2')" readonly
/></label>
</div>
@if (!isMutualFund && purchaseCouponDates) {
<div class="pos-section-label">Interest Cycle</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Interest Dates (half-yearly)</span><input type="text" [value]="purchaseCouponDates" readonly
/></label>
<label class="inv-form-field"
><span>Last Coupon</span><input type="text" [value]="purchaseForm.lastIntDt || '—'" readonly
/></label>
<label class="inv-form-field"
><span>Next Coupon</span><input type="text" [value]="purchaseNextCouponDate || '—'" readonly
/></label>
<label class="inv-form-field"
><span>Next Coupon Amount</span
><input type="text" [value]="'₹' + (purchaseNextCouponAmount | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Days to Next Coupon</span><input type="text" [value]="purchaseDaysToNextCoupon" readonly
/></label>
</div>
}
<!-- 3. Accounting preview -->
<div class="pos-section-label">Accounting Preview (posted after checker approval)</div>
@if (purchaseAccountingPreview.length) {
<table class="pg-table">
<thead>
<tr>
<th>Dr / Cr</th>
<th>GL Code</th>
<th>GL Name</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (l of purchaseAccountingPreview; track l.side + l.glCode) {
<tr>
<td>{{ l.side }}</td>
<td>{{ l.glCode }}</td>
<td>{{ l.glName || '—' }}</td>
<td class="num">₹{{ l.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<p class="pg-sub">Select a script, enter the amount and CR. To A/c to see the entry.</p>
}
<!-- 4. More details — optional, collapsed by default -->
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="showMoreDetails = !showMoreDetails">
{{ showMoreDetails ? '▾ Hide More Details' : '▸ More Details (optional)' }}
</button>
</div>
@if (showMoreDetails) {
<div class="pos-section-label">More Details (optional)</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Sub Type</span>
<select [(ngModel)]="purchaseForm.subType">
@for (s of subTypeOptions; track s.subTypeCode + s.subTypeName) {
<option [value]="s.subTypeName">{{ s.subTypeName }}</option>
}
</select>
</label>
@if (isMutualFund) {
<label class="inv-form-field"
><span>Trade Amt</span><input type="number" [(ngModel)]="purchaseForm.tradeAmt" min="0"
/></label>
<label class="inv-form-field"
><span>Stamp Duty A/c</span><input type="text" [(ngModel)]="purchaseForm.stampDutyAc"
/></label>
<label class="inv-form-field"
><span>Holding Days</span><input type="number" [(ngModel)]="purchaseForm.holdingDays" min="0"
/></label>
} @else {
<label class="inv-form-field">
<span>Int Type</span>
<select [(ngModel)]="purchaseForm.intType">
@for (i of intTypeOptions; track i.value) {
<option [value]="i.value">{{ i.label }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Bond Rating</span>
<select [(ngModel)]="purchaseForm.bondRating">
@for (r of bondRatingOptions; track r) {
<option [value]="r">{{ r }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>SGL with Bank</span>
<select [(ngModel)]="purchaseForm.sglWithBank">
<option value="">-- Select --</option>
@for (s of sglBankOptions; track s.sglCode) {
<option [value]="s.bankName">{{ s.bankName }} — {{ s.branch }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>TBill GL A/c</span><input type="text" [(ngModel)]="purchaseForm.tbillGlAc"
/></label>
}
<label class="inv-form-field">
<span>Broker</span>
<select [(ngModel)]="purchaseForm.brokerName">
<option value="">-- Select --</option>
@for (b of brokerOptions; track b.brokerCode) {
<option [value]="b.brokerCode">{{ b.brokerCode }} — {{ b.brokerName }}</option>
<span>Int Type</span>
<select [(ngModel)]="purchaseForm.intType">
@for (i of intTypeOptions; track i.value) {
<option [value]="i.value">{{ i.label }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Trade No.</span><input type="text" [(ngModel)]="purchaseForm.tradeNo"
/></label>
<label class="inv-form-field"
><span>Order No.</span><input type="text" [(ngModel)]="purchaseForm.orderNo"
/></label>
<label class="inv-form-field">
<span>Recomm. Authority</span>
<select [(ngModel)]="purchaseForm.recommAuthority">
<option value="">-- Select --</option>
@for (a of authorityOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Sanction Authority</span>
<select [(ngModel)]="purchaseForm.sanctionAuthority">
<option value="">-- Select --</option>
@for (a of authorityOptions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Meet Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.meetDt"></mifosx-date-ddmmyyyy
><span>Int Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.intDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Reso No</span><input type="text" [(ngModel)]="purchaseForm.resoNo"
><span>* Last Int.dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.lastIntDt"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Accrued Interest (computed)</span
><input type="text" [value]="purchaseAccruedInterest | number: '1.2-2'" readonly
/></label>
<label class="inv-form-field"
><span>Settlement Amt of Pur. (computed)</span
><input type="text" [value]="purchaseSettlementAmount | number: '1.4-4'" readonly
/></label>
<label class="inv-form-field">
<span>Deal Type</span>
@@ -522,80 +423,6 @@
}
</select>
</label>
<label class="inv-form-field">
<span>Payment Mode</span>
<select [(ngModel)]="purchaseForm.paymentMode">
<option value="">-- Select --</option>
@for (p of paymentModeOptions; track p) {
<option [value]="p">{{ p }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Delivery Mode</span>
<select [(ngModel)]="purchaseForm.deliveryMode">
<option value="">-- Select --</option>
@for (d of deliveryModeOptions; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>LIEN</span>
<select [(ngModel)]="purchaseForm.lien">
<option value="NO">NO</option>
<option value="YES">YES</option>
</select>
</label>
@if (purchaseForm.lien === 'YES') {
<label class="inv-form-field"
><span>LIEN Amount</span><input type="number" [(ngModel)]="purchaseForm.lienAmount"
/></label>
<label class="inv-form-field">
<span>LIEN Reason</span>
<select [(ngModel)]="purchaseForm.lienReason">
<option value="">-- Select --</option>
@for (l of lienReasonOptions; track l.reasonCode) {
<option [value]="l.reasonDescription">{{ l.reasonDescription }}</option>
}
</select>
</label>
}
<label class="inv-form-field"
><span>Folio No</span><input type="text" [(ngModel)]="purchaseForm.folioNo"
/></label>
<label class="inv-form-field"
><span>Call Option</span><input type="text" [(ngModel)]="purchaseForm.callOption"
/></label>
<label class="inv-form-field"
><span>Register Address</span><input type="text" [(ngModel)]="purchaseForm.registerAdd"
/></label>
<label class="inv-form-field"
><span>Register City</span><input type="text" [(ngModel)]="purchaseForm.regiCity"
/></label>
<label class="inv-form-field"
><span>Register Pin</span><input type="text" [(ngModel)]="purchaseForm.regiPin"
/></label>
<label class="inv-form-field"
><span>Issue Date (override)</span
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.issueDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Redum / Mat Date (override)</span
><input type="text" [(ngModel)]="purchaseForm.redumMatDt" placeholder="DD-MM-YYYY"
/></label>
<label class="inv-form-field"
><span>Int Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.intDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Desc Inv</span><input type="text" [(ngModel)]="purchaseForm.descInv"
/></label>
<label class="inv-form-field"
><span>From Dist</span><input type="text" [(ngModel)]="purchaseForm.fromDist"
/></label>
<label class="inv-form-field"
><span>To Dist</span><input type="text" [(ngModel)]="purchaseForm.toDist"
/></label>
<label class="inv-form-field">
<span>Mark CBLO</span>
<select [(ngModel)]="purchaseForm.markCblo">
@@ -606,6 +433,14 @@
<label class="inv-form-field"
><span>Amortize Amt.</span><input type="number" [(ngModel)]="purchaseForm.amortizeAmt"
/></label>
<label class="inv-form-field">
<span>Bond Rating</span>
<select [(ngModel)]="purchaseForm.bondRating">
@for (r of bondRatingOptions; track r) {
<option [value]="r">{{ r }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Corp. Bond</span>
<select [(ngModel)]="purchaseForm.corpBond">
@@ -613,19 +448,76 @@
<option value="YES">YES</option>
</select>
</label>
<label class="inv-form-field">
<span>SGL with Bank</span>
<select [(ngModel)]="purchaseForm.sglWithBank">
<option value="">-- Select --</option>
@for (s of sglBankOptions; track s.sglCode) {
<option [value]="s.bankName">{{ s.bankName }} — {{ s.branch }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Remarks</span><input type="text" [(ngModel)]="purchaseForm.remarks"
><span>Register Add</span><input type="text" [(ngModel)]="purchaseForm.registerAdd"
/></label>
<label class="inv-form-field"
><span>Description</span><input type="text" [(ngModel)]="purchaseForm.description"
><span>Regi City</span><input type="text" [(ngModel)]="purchaseForm.regiCity"
/></label>
</div>
}
<div class="inv-form-actions">
<label class="inv-form-field"
><span>Regi Pin</span><input type="text" [(ngModel)]="purchaseForm.regiPin"
/></label>
}
<label class="inv-form-field">
<span>LIEN</span>
<select [(ngModel)]="purchaseForm.lien">
<option value="NO">NO</option>
<option value="YES">YES</option>
</select>
</label>
@if (purchaseForm.lien === 'YES') {
<label class="inv-form-field"
><span>LIEN Amount</span><input type="number" [(ngModel)]="purchaseForm.lienAmount"
/></label>
<label class="inv-form-field">
<span>LIEN Reason</span>
<select [(ngModel)]="purchaseForm.lienReason">
<option value="">-- Select --</option>
@for (l of lienReasonOptions; track l.reasonCode) {
<option [value]="l.reasonDescription">{{ l.reasonDescription }}</option>
}
</select>
</label>
}
<label class="inv-form-field">
<span>Payment Mode</span>
<select [(ngModel)]="purchaseForm.paymentMode">
<option value="">--Select--</option>
@for (p of paymentModeOptions; track p) {
<option [value]="p">{{ p }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>Delivery Mode</span>
<select [(ngModel)]="purchaseForm.deliveryMode">
<option value="">--Select--</option>
@for (d of deliveryModeOptions; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>Retrieve Cert No</span><input type="text" placeholder="e.g. 30" [(ngModel)]="retrieveCertNo"
><span>Remarks</span><input type="text" [(ngModel)]="purchaseForm.remarks"
/></label>
<label class="inv-form-field"
><span>Description</span><input type="text" [(ngModel)]="purchaseForm.description"
/></label>
<label class="inv-form-field"
><span>Maturity / Redemption Value (computed)</span
><input type="text" [value]="'₹' + (purchaseMaturityAmount | number: '1.2-2')" readonly
/></label>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="savePurchase()">Save</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="retrievePurchase()">Retrieve</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropPurchase()">Drop</button>
@@ -677,14 +569,20 @@
</div>
}
<!-- ===================== 03 INTEREST ENTRY ===================== -->
<!-- ===================== 03 INTEREST ENTRY / CALCULATION ===================== -->
@if (activeTab === 'interest') {
<div class="pg-card">
<div class="pos-section-label">Interest Entry — coupon and 31 Mar / 30 Sep provision schedule</div>
<div class="pos-section-label">Investment - Govt./Bond Interest calculation</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcFromDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Type</span>
<select [(ngModel)]="intType" (ngModelChange)="onIntFilterChange()">
<select [(ngModel)]="intCalcType" (ngModelChange)="onIntCalcTypeChange()">
<option value="">-- All Types --</option>
@for (o of bondTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
@@ -693,149 +591,67 @@
</label>
<label class="inv-form-field">
<span>Script</span>
<select [(ngModel)]="intScript" (ngModelChange)="onIntFilterChange()">
<select [(ngModel)]="intCalcScript">
<option value="">-- All Scripts --</option>
@for (s of intScriptOptions; track s.scriptCode) {
@for (s of scriptOptions; track s.scriptCode) {
<option [value]="s.scriptCode">{{ s.scriptCode }} — {{ s.scriptName }}</option>
}
</select>
</label>
<label class="inv-form-field"><span>Deal No</span><input type="text" [(ngModel)]="intCalcDealNo" /></label>
<label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="intCalcAll" style="height: 18px; width: 18px"
/></label>
<label class="inv-form-field">
<span>* Deal No</span>
<select [(ngModel)]="intPurchaseId" (ngModelChange)="intSchedule = null">
<option [ngValue]="null">-- Select --</option>
@for (h of intDealOptions; track h.id) {
<option [ngValue]="h.id">{{ h.dealNo }} — {{ h.scriptCode }} (Cert {{ h.certNo }})</option>
<span>Post To (Interest Income GL)</span>
<select [(ngModel)]="intCalcGlCode">
@for (g of interestIncomeGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>As On Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intAsOnDate"></mifosx-date-ddmmyyyy
></label>
</div>
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="intLoading"
(click)="retrieveInterestSchedule()"
>
{{ intLoading ? 'Retrieving…' : 'Retrieve' }}
</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="processInterest()">Process</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="setTab('holdings')">Exit</button>
</div>
@if (intSchedule; as sc) {
<div class="pos-section-label">Purchase Details</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Deal No / Cert No</span><input type="text" [value]="sc.dealNo + ' / ' + sc.certNo" readonly
/></label>
<label class="inv-form-field"
><span>Script</span><input type="text" [value]="sc.scriptCode + ' — ' + sc.scriptDesc" readonly
/></label>
<label class="inv-form-field"
><span>Face Value Held</span><input type="text" [value]="'₹' + (sc.faceValue | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Interest Rate</span><input type="text" [value]="sc.intRate + '%'" readonly
/></label>
<label class="inv-form-field"
><span>Coupon per Half-Year</span
><input type="text" [value]="'₹' + (sc.couponPerHalfYear | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Deal Date</span><input type="text" [value]="sc.dealDate" readonly
/></label>
<label class="inv-form-field"
><span>Last Interest Date</span><input type="text" [value]="sc.lastIntDate" readonly
/></label>
<label class="inv-form-field"
><span>Maturity Date</span><input type="text" [value]="sc.maturityDate" readonly
/></label>
<label class="inv-form-field"
><span>Broken Period Interest Paid (expense 500606)</span
><input type="text" [value]="'₹' + (sc.purchasedAccruedInterest | number: '1.2-2')" readonly
/></label>
<label class="inv-form-field"
><span>Book Value</span><input type="text" [value]="'₹' + (sc.bookValue | number: '1.2-2')" readonly
/></label>
</div>
<div class="pos-section-label">
Interest Schedule ({{ sc.rows.length }} lines) — due up to {{ sc.asOnDate }}: ₹{{
intDueTotal | number: '1.2-2'
}}
</div>
<p class="pg-sub" style="padding: 0 16px">
Days are 30/360. A provision books the interest earned since the last coupon; the coupon then clears what is
already booked. Display only — nothing is posted to GL yet.
</p>
<table class="pg-table">
<thead>
<tr>
<th>Type</th>
<th>Int Date</th>
<th>From</th>
<th>To</th>
<th class="num">Days</th>
<th class="num">Int %</th>
<th class="num">Gross Interest</th>
<th class="num">Already Booked</th>
<th class="num col-in">Amount</th>
<th>Accounting</th>
</tr>
</thead>
<tbody>
@for (r of sc.rows; track r.intDate) {
<tr [style.opacity]="r.due ? 1 : 0.6">
<td>
<span class="pg-chip">{{ r.type === 'COUPON' ? 'Coupon' : 'Provision' }}</span>
@if (r.due) {
<span class="pg-chip">Due</span>
}
</td>
<td>{{ r.intDate }}</td>
<td>{{ r.fromDate }}</td>
<td>{{ r.toDate }}</td>
<td class="num">{{ r.days }}</td>
<td class="num">{{ sc.intRate }}</td>
<td class="num">₹{{ r.grossInterest | number: '1.2-2' }}</td>
<td class="num">₹{{ r.alreadyBooked | number: '1.2-2' }}</td>
<td class="num col-in">₹{{ r.amount | number: '1.2-2' }}</td>
<td style="white-space: nowrap; font-size: 12px">
@if (r.type === 'COUPON') {
<div>Dr Bank ₹{{ r.grossInterest | number: '1.0-0' }}</div>
@if (r.alreadyBooked > 0) {
<div>
Cr {{ interestReceivableGl.glCode }} Int. Receivable ₹{{ r.alreadyBooked | number: '1.0-0' }}
</div>
}
<div>Cr {{ sc.interestGlCode }} Int. Income ₹{{ r.amount | number: '1.0-0' }}</div>
} @else {
<div>Dr {{ interestReceivableGl.glCode }} Int. Receivable ₹{{ r.amount | number: '1.0-0' }}</div>
<div>Cr {{ sc.interestGlCode }} Int. Income ₹{{ r.amount | number: '1.0-0' }}</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="pg-empty">
No interest lines — check the rate, last interest date and maturity date.
</td>
</tr>
}
</tbody>
</table>
<p class="pg-sub" style="padding: 8px 16px 12px">
Interest income GL: {{ sc.interestGlCode }} — {{ sc.interestGlName }}. Posting to GL will be enabled after
approval.
</p>
} @else {
@if (intCalcExcludedNpi.length > 0) {
<p class="pg-sub" style="padding: 0 16px 12px">
Select a Deal No and press Retrieve to see its interest schedule.
{{ intCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on
accrual basis (collectible on cash basis only, via Interest Receive in the Investment Interest module).
</p>
}
<div class="pos-section-label">Holdings matching the filter ({{ intCalcTargets.length }})</div>
<table class="pg-table">
<thead>
<tr>
<th>Cert No</th>
<th>Script</th>
<th>Type</th>
<th class="num">Rate</th>
<th>Frequency</th>
<th class="num col-in">Interest to Post</th>
</tr>
</thead>
<tbody>
@for (h of intCalcTargets; track h.certNo) {
<tr>
<td>
<span class="pg-chip">{{ h.certNo }}</span>
</td>
<td>{{ h.scriptName }}</td>
<td>{{ instrumentLabel(h.instrumentType) }}</td>
<td class="num">{{ h.interestRate }}%</td>
<td>{{ h.interestFreq }}</td>
<td class="num col-in">₹{{ interestAccrued(h) | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="pg-empty">No holdings match the selected filters.</td>
</tr>
}
</tbody>
</table>
</div>
}
@@ -6,11 +6,16 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS,
BOND_HOLDINGS,
MUTUAL_FUND_HOLDINGS,
ALL_TXNS,
SCRIPT_MASTER,
PARTY_MASTER,
SUBTYPE_MASTER,
@@ -26,26 +31,16 @@ import {
BOND_RATING_OPTIONS,
INT_TYPE_OPTIONS,
AUTHORITY_OPTIONS,
INVESTMENT_GL_BY_TYPE,
INVESTMENT_GL_LOOKUP,
INTEREST_RECEIVABLE_GL,
BROKEN_PERIOD_INTEREST_GL,
STAMP_DUTY_GL,
BROKERAGE_GL,
OTHER_CHARGES_GL,
SETTLEMENT_BANK_GL_OPTIONS,
periodBetween,
parseDDMMYYYY,
days360,
lastHalfYearlyCouponDate,
nextHalfYearlyCouponDate,
couponDatesLabel,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
InvestmentHolding,
InvestmentTxn,
InstrumentType,
InterestFreq,
INSTRUMENT_LABELS,
accruedInterest,
accruedInterestBetween,
canAccrueInterest,
daysToMaturity,
maturityAmount,
addPeriod,
@@ -54,14 +49,6 @@ import {
todayDDMMYYYY
} from '../investment-data';
import { exportWorkbook } from '../investment-export';
import {
InvestmentDailyRateDto,
InvestmentInterestScheduleDto,
InvestmentPurchaseDto,
InvestmentService,
apiErrorMessage,
toHolding
} from '../investment.service';
type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND';
type BondTab =
@@ -142,14 +129,6 @@ interface BondPurchaseForm {
holdingDays: number | null;
stampDuty: number | null;
stampDutyAc: string;
otherCharges: number | null;
}
interface AccountingPreviewLine {
side: 'Dr' | 'Cr';
glCode: string;
glName: string;
amount: number;
}
interface BondSellForm {
@@ -203,67 +182,23 @@ interface BondSellForm {
DateDdmmyyyyPickerComponent
]
})
export class BondInvestmentComponent implements OnInit {
private readonly investmentService = inject(InvestmentService);
/** Script and party dropdowns offer the masters saved in the backend. */
ngOnInit(): void {
this.investmentService.syncSharedMasters().subscribe({
error: (err) => this.toast(`Could not load Script / Party masters: ${apiErrorMessage(err)}`)
});
this.investmentService.getAssetGlAccounts().subscribe({
next: (gls) =>
(this.glIdByCode = new Map(
gls.map((g) => [
g.glCode,
g.id
])
)),
error: (err) => this.toast(`Could not load GL accounts: ${apiErrorMessage(err)}`)
});
this.loadPurchases();
}
/** GL id for a GL code — the purchase API takes ids, the form works with codes. */
private glIdByCode = new Map<string, number>();
/** The Holdings register and transaction log are the purchases saved in the backend. */
private loadPurchases(): void {
this.investmentService.getPurchases().subscribe({
next: (purchases) => {
this.holdings = purchases.map(toHolding);
this.txns = purchases.map((d) => this.purchaseTxn(d));
this.purchaseForm = this.blankPurchaseForm();
},
error: (err) => this.toast(`Could not load purchases: ${apiErrorMessage(err)}`)
});
}
private purchaseTxn(d: InvestmentPurchaseDto): InvestmentTxn {
return {
certNo: d.certNo,
instrumentType: d.bondType,
txnType: 'PURCHASE',
voucherNo: `INV/PUR/${d.dealNo}`,
txnDate: d.dealDate,
amount: d.settlementAmount,
particulars: `Purchase Deal ${d.dealNo} - ${d.scriptDesc} - ${d.partyName} [${d.category}]`,
glCode: d.investmentGlCode,
glName: d.investmentGlName
};
}
export class BondInvestmentComponent {
activeTab: BondTab = 'holdings';
/** Purchase Entry shows only the mandatory fields; the rest sit under "More Details". */
showMoreDetails = false;
/** Cert No typed in the Retrieve box (Cert No itself is system-generated and read-only). */
retrieveCertNo = '';
bondType: BondType = 'GOVT_SECURITY';
asOnDate = todayDDMMYYYY();
/** Loaded from the backend (saved purchases) in ngOnInit. */
holdings: InvestmentHolding[] = [];
txns: InvestmentTxn[] = [];
holdings: InvestmentHolding[] = [
...GOVT_SECURITY_HOLDINGS,
...NON_SLR_HOLDINGS,
...BOND_HOLDINGS,
...MUTUAL_FUND_HOLDINGS
];
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => ([
'GOVT_SECURITY',
'NON_SLR',
'BOND',
'MUTUAL_FUND'
] as InstrumentType[]).includes(t.instrumentType));
// Master-driven dropdown sources
readonly scriptOptions = SCRIPT_MASTER;
@@ -289,23 +224,23 @@ export class BondInvestmentComponent implements OnInit {
purchaseForm: BondPurchaseForm = this.blankPurchaseForm();
sellForm: BondSellForm = this.blankSellForm();
readonly settlementBankOptions = SETTLEMENT_BANK_GL_OPTIONS;
// Redemption Entry
redeemCertNo = '';
redeemDate = this.asOnDate;
redeemRemarks = '';
// 03 Interest Entry (legacy: Type / Script / Deal No + Retrieve) — the backend works out every
// half-yearly coupon and 31 Mar / 30 Sep closing provision of the holding. Display only:
// nothing is posted to GL until the posting rules are approved.
intType: BondType | '' = '';
intScript = '';
intPurchaseId: number | null = null;
intAsOnDate = this.asOnDate;
intSchedule: InvestmentInterestScheduleDto | null = null;
intLoading = false;
readonly interestReceivableGl = INTEREST_RECEIVABLE_GL;
// Interest Calculation (legacy: From/To Date / Type / Script / Deal No / ALL + Process) —
// posts interest accrued strictly within [intCalcFromDate, intCalcToDate], same period-based
// basis as the Investment Interest module's Govt./Bond Interest Calculation.
intCalcFromDate = addPeriod(this.asOnDate, 0, -3);
intCalcToDate = this.asOnDate;
intCalcType: BondType | '' = '';
intCalcScript = '';
intCalcDealNo = '';
intCalcAll = false;
intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.GOVT_SECURITY;
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
// Write-off
writeoffCertNo = '';
@@ -370,38 +305,20 @@ export class BondInvestmentComponent implements OnInit {
return INSTRUMENT_LABELS[type];
}
/** Preview of the deal number the backend will assign: FY prefix (e.g. 202627) + running number. */
private nextDealNo(): string {
const d = parseDDMMYYYY(this.asOnDate);
const fyStart = d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1;
const prefix = `${fyStart}${String((fyStart + 1) % 100).padStart(2, '0')}`;
const nums = (this.holdings ?? [])
.map((h) => h.dealNo)
.filter((n): n is string => !!n && n.startsWith(prefix))
.map((n) => parseInt(n.slice(prefix.length), 10));
return `${prefix}${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(6, '0')}`;
}
/** Certificate No is system-generated, as in the existing CBS (greyed there). */
/** Preview of the certificate number the backend will assign (running number). */
private nextCertNo(): string {
const nums = (this.holdings ?? []).map((h) => parseInt(h.certNo, 10)).filter((n) => !isNaN(n));
return String((nums.length ? Math.max(...nums) : 0) + 1);
return `2026${String(27000039 + this.holdings.length).padStart(9, '0')}`;
}
private blankPurchaseForm(): BondPurchaseForm {
const type = this.bondType ?? 'GOVT_SECURITY';
this.showMoreDetails = false;
this.retrieveCertNo = '';
this.purchaseMarketRate = undefined;
return {
bondType: type,
dealNo: this.nextDealNo(),
dealDate: this.asOnDate,
dealTime: new Date().toTimeString().slice(0, 8),
dealTime: '',
isin: '',
scriptCode: '',
certNo: this.nextCertNo(),
certNo: '',
settlementType: 'T+0',
settlementDt: this.asOnDate,
faceValue: null,
@@ -409,7 +326,7 @@ export class BondInvestmentComponent implements OnInit {
partyCode: '',
subType: type === 'GOVT_SECURITY' ? 'SLR' : type === 'NON_SLR' ? 'NON SLR' : 'CORPORATE BOND',
tbillGlAc: '',
crToAc: SETTLEMENT_BANK_GL_OPTIONS[0]?.glCode ?? '',
crToAc: '310070',
premiumAmt: null,
brokerage: null,
brokerName: '',
@@ -457,8 +374,7 @@ export class BondInvestmentComponent implements OnInit {
nav: null,
holdingDays: null,
stampDuty: null,
stampDutyAc: '',
otherCharges: null
stampDutyAc: ''
};
}
@@ -528,41 +444,14 @@ export class BondInvestmentComponent implements OnInit {
return this.isMutualFund ? this.purchaseForm.nav || 0 : 100;
}
/** Book Value — the price paid for the security. Charges are expenses and stay out of it (Jitendra Sir). */
/** Book Value — what the investment is carried at, i.e. amount actually paid. */
get purchaseBookValue(): number {
const f = this.purchaseForm;
if (this.isMutualFund) {
return this.purchaseBaseAmount;
return this.purchaseBaseAmount + (f.stampDuty || 0) + (f.brokerage || 0);
}
const rate = f.purchaseRate || 100;
return Math.round((this.purchaseBaseAmount * rate) / 100);
}
/** Stamp duty + brokerage + other charges — paid with the settlement, booked to expense GLs. */
get purchaseTotalCharges(): number {
const f = this.purchaseForm;
return (f.stampDuty || 0) + (f.brokerage || 0) + (f.otherCharges || 0);
}
// Interest cycle of the security being bought: its two coupon days and the next coupon after the deal.
get purchaseCouponDates(): string {
return this.purchaseMaturityDate ? couponDatesLabel(this.purchaseMaturityDate) : '';
}
get purchaseNextCouponDate(): string {
const f = this.purchaseForm;
return this.purchaseMaturityDate && f.dealDate
? nextHalfYearlyCouponDate(this.purchaseMaturityDate, f.dealDate)
: '';
}
get purchaseNextCouponAmount(): number {
const f = this.purchaseForm;
return f.intRate ? Math.round((this.purchaseBaseAmount * f.intRate) / 200) : 0;
}
get purchaseDaysToNextCoupon(): number {
return this.purchaseNextCouponDate ? days360(this.purchaseForm.dealDate, this.purchaseNextCouponDate) : 0;
return Math.round((this.purchaseBaseAmount * rate) / 100) + (f.brokerage || 0);
}
get purchaseYtm(): number {
@@ -574,47 +463,18 @@ export class BondInvestmentComponent implements OnInit {
}
/** Accrued Interest at purchase — interest earned by the seller since the last coupon date. */
/** Interest owed to the seller from the last coupon date up to the deal date, on a 30/360 basis —
* interest is counted to the event date, not the date the money moves (Jitendra Sir). */
get purchaseAccruedInterest(): number {
const f = this.purchaseForm;
if (!f.intRate || !f.lastIntDt || !f.dealDate) {
return 0;
}
return Math.round((this.purchaseBaseAmount * (f.intRate / 100) * days360(f.lastIntDt, f.dealDate)) / 360);
const yrs = Math.max(0, yearsBetween(f.lastIntDt, f.dealDate));
return Math.round(this.purchaseBaseAmount * (f.intRate / 100) * yrs);
}
get purchaseAccruedDays(): number {
const f = this.purchaseForm;
return f.lastIntDt && f.dealDate ? days360(f.lastIntDt, f.dealDate) : 0;
}
/** Premium (+) or discount (−) paid over face value. */
get purchasePremiumDiscount(): number {
const f = this.purchaseForm;
if (this.isMutualFund || !f.faceValue || !f.purchaseRate) {
return 0;
}
return Math.round((f.faceValue * (f.purchaseRate - 100)) / 100);
}
/** Re-suggests the last coupon date when the deal date changes. */
onDealDateChange(): void {
this.suggestLastIntDate();
this.loadMarketRate();
}
private suggestLastIntDate(): void {
const f = this.purchaseForm;
const s = this.purchaseScript;
if (!this.isMutualFund && s?.maturityDate && f.dealDate) {
f.lastIntDt = lastHalfYearlyCouponDate(s.maturityDate, f.dealDate) || f.lastIntDt;
}
}
/** Settlement Amt of Purchase — book value, accrued interest payable to the seller and the charges. */
/** Settlement Amt of Purchase — book value plus accrued interest payable to the seller. */
get purchaseSettlementAmount(): number {
return this.purchaseBookValue + this.purchaseAccruedInterest + this.purchaseTotalCharges;
return this.purchaseBookValue + this.purchaseAccruedInterest;
}
get purchaseMaturityAmount(): number {
@@ -644,262 +504,69 @@ export class BondInvestmentComponent implements OnInit {
});
}
get purchaseScript(): (typeof SCRIPT_MASTER)[number] | undefined {
return this.scriptOptions.find((x) => x.scriptCode === this.purchaseForm.scriptCode);
}
/** Picking a script pulls its master details into the deal, as the existing CBS does. */
/** Fills ISIN + interest rate when a script is picked, mirroring the legacy form's lookup. */
onScriptChange(): void {
const s = this.purchaseScript;
if (!s) {
return;
const s = this.scriptOptions.find((x) => x.scriptCode === this.purchaseForm.scriptCode);
if (s) {
this.purchaseForm.isin = s.isin;
}
const f = this.purchaseForm;
f.isin = s.isin;
if (s.couponRate != null) {
f.intRate = s.couponRate;
}
if (s.issueDate) {
f.issueDate = s.issueDate;
}
if (s.maturityDate) {
f.redumMatDt = s.maturityDate;
const p = periodBetween(s.issueDate, s.maturityDate);
f.periodYear = p.years;
f.periodMonth = p.months;
f.periodDays = p.days;
}
if (s.slrType) {
f.subType = s.slrType === 'NON_SLR' ? 'NON SLR' : 'SLR';
}
if (this.isMutualFund) {
f.productName = s.scriptName;
}
this.suggestLastIntDate();
this.loadMarketRate();
}
/**
* Market rate of the script for the deal date from the Daily Script Master (newest rate on or before it). It fills
* the purchase rate (NAV for a mutual fund); the dealer may still change it. undefined = not looked up yet,
* null = no rate entered for the script.
*/
purchaseMarketRate: InvestmentDailyRateDto | null | undefined;
private loadMarketRate(): void {
const s = this.purchaseScript;
const f = this.purchaseForm;
if (!s?.id || !f.dealDate) {
this.purchaseMarketRate = undefined;
return;
}
this.investmentService.getLatestDailyRate(s.id, f.dealDate).subscribe({
next: (rate) => {
this.purchaseMarketRate = rate;
// No market rate: clear the field rather than leave another script's rate in it.
if (this.isMutualFund) {
f.nav = rate ? rate.closeRate : null;
} else {
f.purchaseRate = rate ? rate.closeRate : null;
}
},
error: (err) => this.toast(`Could not read the market rate: ${apiErrorMessage(err)}`)
});
}
/** True when the rate entered is outside the day's low–high in the Daily Script Master. */
get purchaseRateOutOfRange(): boolean {
const m = this.purchaseMarketRate;
const f = this.purchaseForm;
const rate = this.isMutualFund ? f.nav : f.purchaseRate;
if (!m || rate == null) {
return false;
}
return (m.lowRate != null && rate < m.lowRate) || (m.highRate != null && rate > m.highRate);
}
/** Settlement Date = Deal Date + T+n days (greyed / auto in the existing CBS). */
get purchaseSettlementDate(): string {
const f = this.purchaseForm;
if (!f.dealDate) {
return '';
}
const n = parseInt(f.settlementType.replace('T+', ''), 10) || 0;
return addPeriod(f.dealDate, 0, 0, n);
}
/** Parties offered for a new deal — inactive parties are hidden. */
get activePartyOptions(): typeof PARTY_MASTER {
return this.partyOptions.filter((p) => p.status !== 'INACTIVE');
}
get purchaseInvestmentGlCode(): string {
return this.purchaseScript?.glCode || INVESTMENT_GL_BY_TYPE[this.purchaseForm.bondType];
}
glNameOf(glCode: string): string {
return INVESTMENT_GL_LOOKUP.find((g) => g.glCode === glCode)?.glName ?? '';
}
/** The journal entry this purchase will post once the checker approves it. */
get purchaseAccountingPreview(): AccountingPreviewLine[] {
const book = this.purchaseBookValue;
const accrued = this.purchaseAccruedInterest;
const total = this.purchaseSettlementAmount;
const crGl = this.purchaseForm.crToAc;
if (!book || !crGl) {
return [];
}
const invGl = this.purchaseInvestmentGlCode;
const lines: AccountingPreviewLine[] = [
{ side: 'Dr', glCode: invGl, glName: this.glNameOf(invGl), amount: book }];
// Broken-period interest paid to the seller is an expense (RBI para 152, confirmed by Jitendra Sir).
if (accrued > 0) {
lines.push({
side: 'Dr',
glCode: BROKEN_PERIOD_INTEREST_GL.glCode,
glName: BROKEN_PERIOD_INTEREST_GL.glName,
amount: accrued
});
}
const f = this.purchaseForm;
for (const [
gl,
amount
] of [
[
STAMP_DUTY_GL,
f.stampDuty
],
[
BROKERAGE_GL,
f.brokerage
],
[
OTHER_CHARGES_GL,
f.otherCharges
]
] as const) {
if (amount && amount > 0) {
lines.push({ side: 'Dr', glCode: gl.glCode, glName: gl.glName, amount });
}
}
lines.push({ side: 'Cr', glCode: crGl, glName: this.glNameOf(crGl), amount: total });
return lines;
}
savePurchase(): void {
const f = this.purchaseForm;
const missing: string[] = [];
if (!f.scriptCode) missing.push('Script Code');
if (!f.partyCode) missing.push('Party Code');
if (!f.dealDate) missing.push('Deal Date');
if (!f.dealTime) missing.push('Deal Time');
if (this.isMutualFund) {
if (!f.considerAmt) missing.push('Consider Amt');
if (!f.nav) missing.push('NAV');
} else {
if (!f.faceValue) missing.push('Face Value');
if (!f.purchaseRate) missing.push('Purchase Rate');
if (!f.lastIntDt) missing.push('Last Interest Date');
}
if (!f.category) missing.push('Category');
if (!f.crToAc) missing.push('CR. To A/c');
if (missing.length) {
this.toast(`Please fill: ${missing.join(', ')}.`);
if (!f.certNo || !this.purchaseBaseAmount || !f.partyCode) {
this.toast('Please fill Cert No, Face Value / Consider Amt and Party Code.');
return;
}
const script = this.purchaseScript;
const party = this.partyOptions.find((p) => p.partyCode === f.partyCode);
const crGlAccountId = this.glIdByCode.get(f.crToAc);
if (!script?.id || !party?.id) {
this.toast('Script and Party must be saved in Investment Masters first.');
return;
}
if (!crGlAccountId) {
this.toast(`GL ${f.crToAc} was not found in the chart of accounts.`);
return;
}
const body = {
bondType: f.bondType,
scriptId: script.id,
partyId: party.id,
dealDate: f.dealDate,
dealTime: f.dealTime || '',
settlementType: f.settlementType,
...(this.isMutualFund
? { considerAmt: f.considerAmt, nav: f.nav }
: { faceValue: f.faceValue, purchaseRate: f.purchaseRate, lastIntDate: f.lastIntDt }),
stampDuty: f.stampDuty ?? null,
brokerage: f.brokerage ?? null,
otherCharges: f.otherCharges ?? null,
category: f.category,
crGlAccountId,
subType: f.subType || '',
otherDetails: this.purchaseOtherDetails(),
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
this.investmentService.createPurchase(body).subscribe({
next: (res) => {
this.toast(`Deal ${res.resourceIdentifier} saved. GL posting will follow once accounting is approved.`);
this.activeTab = 'holdings';
this.loadPurchases();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
/** The optional "More Details" fields, kept with the deal as they were entered. */
private purchaseOtherDetails(): Record<string, string | number> {
const f = this.purchaseForm;
const fields: Record<string, string | number | null> = {
tradeNo: f.tradeNo,
orderNo: f.orderNo,
const holding: InvestmentHolding = {
certNo: f.certNo,
instrumentType: f.bondType,
scriptCode: f.scriptCode || f.isin || f.productName,
scriptName: this.isMutualFund
? f.productName
: this.scriptOptions.find((s) => s.scriptCode === f.scriptCode)?.scriptName || f.scriptCode || f.isin,
party: f.partyCode,
subType: f.subType,
faceValue: this.purchaseBaseAmount,
purchaseRate: this.isMutualFund ? f.nav || 0 : f.purchaseRate || 100,
units: this.purchaseUnits,
purchaseDate: f.issueDate,
maturityDate: this.purchaseMaturityDate || f.issueDate,
interestRate: f.intRate || 0,
interestFreq: this.isMutualFund ? 'CUMULATIVE' : f.intType,
glCode:
'31' +
(f.bondType === 'GOVT_SECURITY'
? '0020'
: f.bondType === 'NON_SLR'
? '0030'
: f.bondType === 'BOND'
? '0040'
: '0050'),
glName: `INVESTMENT IN ${INSTRUMENT_LABELS[f.bondType].toUpperCase()}`,
crToAc: f.crToAc,
brokerCode: f.brokerName,
recommAuthority: f.recommAuthority,
sanctionAuthority: f.sanctionAuthority,
meetDate: f.meetDt,
resoNo: f.resoNo,
dealType: f.dealType,
market: f.market,
markCblo: f.markCblo,
paymentMode: f.paymentMode,
deliveryMode: f.deliveryMode,
lien: f.lien,
lienAmount: f.lienAmount,
lienReason: f.lienReason,
bondRating: f.bondRating,
corpBond: f.corpBond,
sglWithBank: f.sglWithBank,
tbillGlAc: f.tbillGlAc,
intType: f.intType,
intDate: f.intDate,
folioNo: f.folioNo,
callOption: f.callOption,
descInv: f.descInv,
fromDist: f.fromDist,
toDist: f.toDist,
amortizeAmt: f.amortizeAmt,
registerAddress: f.registerAdd,
registerCity: f.regiCity,
registerPin: f.regiPin,
remarks: f.remarks,
description: f.description,
tradeAmt: f.tradeAmt,
holdingDays: f.holdingDays,
stampDutyAc: f.stampDutyAc
status: 'ACTIVE'
};
const out: Record<string, string | number> = {};
for (const [
k,
v
] of Object.entries(fields)) {
if (v !== null && v !== undefined && v !== '') {
out[k] = v;
}
}
return out;
this.holdings = [
holding,
...this.holdings
];
this.txns = [
{
certNo: holding.certNo,
instrumentType: f.bondType,
txnType: 'PURCHASE',
voucherNo: `INV/PUR/${f.dealNo}`,
txnDate: f.dealDate,
amount: this.purchaseSettlementAmount,
particulars: `Purchase Deal ${f.dealNo} - ${holding.scriptName} - ${holding.party}${f.category ? ' [' + f.category + ']' : ''}`
},
...this.txns
];
this.toast(`${INSTRUMENT_LABELS[f.bondType]} Certificate ${holding.certNo} saved successfully.`);
this.purchaseForm = this.blankPurchaseForm();
this.activeTab = 'holdings';
}
cancelPurchase(): void {
@@ -909,10 +576,9 @@ export class BondInvestmentComponent implements OnInit {
/** Legacy "Retrieve" button — loads an existing deal back into the form by Cert No. */
retrievePurchase(): void {
const certNo = this.retrieveCertNo.trim();
const h = this.holdings.find((x) => x.certNo === certNo);
const h = this.holdings.find((x) => x.certNo === this.purchaseForm.certNo.trim());
if (!h) {
this.toast(`Cert No ${certNo || '(blank)'} not found.`);
this.toast(`Cert No ${this.purchaseForm.certNo} not found.`);
return;
}
this.purchaseForm = {
@@ -921,8 +587,7 @@ export class BondInvestmentComponent implements OnInit {
certNo: h.certNo,
scriptCode: h.scriptCode,
isin: this.scriptOptions.find((s) => s.scriptCode === h.scriptCode)?.isin || '',
dealNo: h.dealNo ?? '',
partyCode: h.partyCode ?? '',
partyCode: h.party,
subType: h.subType,
faceValue: h.faceValue,
considerAmt: h.faceValue,
@@ -934,26 +599,22 @@ export class BondInvestmentComponent implements OnInit {
intType: h.interestFreq,
brokerName: h.brokerCode,
crToAc: h.crToAc,
productName: h.scriptName,
category: h.category ?? ''
productName: h.scriptName
};
this.bondType = h.instrumentType as BondType;
this.toast(`Deal for Cert No ${h.certNo} retrieved.`);
}
dropPurchase(): void {
const h = this.holdings.find((x) => x.certNo === this.purchaseForm.certNo);
if (!h?.id) {
this.toast('Retrieve a saved deal first, then Drop.');
const certNo = this.purchaseForm.certNo;
if (!this.holdings.some((h) => h.certNo === certNo)) {
this.toast('Nothing to drop — this deal is not yet saved.');
return;
}
this.investmentService.deletePurchase(h.id).subscribe({
next: () => {
this.toast(`Deal ${h.dealNo} (Cert No ${h.certNo}) dropped.`);
this.loadPurchases();
},
error: (err) => this.toast(apiErrorMessage(err))
});
this.holdings = this.holdings.filter((h) => h.certNo !== certNo);
this.txns = this.txns.filter((t) => t.certNo !== certNo);
this.purchaseForm = this.blankPurchaseForm();
this.toast(`Cert No ${certNo} dropped.`);
}
exitPurchase(): void {
@@ -997,53 +658,78 @@ export class BondInvestmentComponent implements OnInit {
}
// -------------------- Interest calculation --------------------
/** Saved holdings for the Deal No dropdown, narrowed by Type and Script. */
get intDealOptions(): InvestmentHolding[] {
return this.holdings.filter(
(h) =>
h.id != null &&
h.status === 'ACTIVE' &&
(!this.intType || h.instrumentType === this.intType) &&
(!this.intScript || h.scriptCode === this.intScript)
);
}
get intScriptOptions(): typeof SCRIPT_MASTER {
return this.scriptOptions.filter((s) => !this.intType || s.instrumentType === this.intType);
}
onIntFilterChange(): void {
if (this.intScript && !this.intScriptOptions.some((s) => s.scriptCode === this.intScript)) {
this.intScript = '';
/** Holdings the Interest Calculation "Process" run would cover, given the filters. A
* Non-Performing holding is always excluded — same accrual-basis rule as the Investment
* Interest module (it's still collectible on cash basis via Interest Receive there). */
get intCalcTargets(): InvestmentHolding[] {
const base = this.holdings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
if (this.intCalcAll) {
return base;
}
if (this.intPurchaseId != null && !this.intDealOptions.some((h) => h.id === this.intPurchaseId)) {
this.intPurchaseId = null;
}
this.intSchedule = null;
}
retrieveInterestSchedule(): void {
if (this.intPurchaseId == null) {
this.toast('Select a Deal No first.');
return;
}
this.intLoading = true;
this.investmentService.getInterestSchedule(this.intPurchaseId, this.intAsOnDate || undefined).subscribe({
next: (schedule) => {
this.intSchedule = schedule;
this.intLoading = false;
},
error: (err) => {
this.intSchedule = null;
this.intLoading = false;
this.toast(apiErrorMessage(err));
}
return base.filter((h) => {
if (this.intCalcType && h.instrumentType !== this.intCalcType) return false;
if (this.intCalcScript && h.scriptCode !== this.intCalcScript) return false;
if (this.intCalcDealNo && h.certNo !== this.intCalcDealNo.trim()) return false;
return true;
});
}
/** Total of the lines falling due up to the as-on date. */
get intDueTotal(): number {
return (this.intSchedule?.rows ?? []).filter((r) => r.due).reduce((sum, r) => sum + r.amount, 0);
get intCalcExcludedNpi(): InvestmentHolding[] {
return this.holdings.filter((h) => h.status === 'ACTIVE' && !canAccrueInterest(h));
}
/** Re-suggests the Interest Income GL when the Type filter changes — still overridable. */
onIntCalcTypeChange(): void {
if (this.intCalcType) {
this.intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE[this.intCalcType];
}
}
glName(glCode: string): string {
return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode;
}
/** Interest accrued strictly within [intCalcFromDate, intCalcToDate] — a period-based
* Interest Calculation & Post run, same basis as the Investment Interest module. */
interestAccrued(h: InvestmentHolding): number {
return accruedInterestBetween(h, this.intCalcFromDate || h.purchaseDate, this.intCalcToDate || this.asOnDate);
}
processInterest(): void {
const targets = this.intCalcTargets.filter((h) => this.interestAccrued(h) > 0);
if (targets.length === 0) {
this.toast('No holdings have interest due to be posted for this period.');
return;
}
const toDate = this.intCalcToDate || this.asOnDate;
const fromDate = this.intCalcFromDate || toDate;
const glCode = this.intCalcGlCode;
const glName = this.glName(glCode);
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
const amt = this.interestAccrued(h);
h.interestPosted = (h.interestPosted ?? 0) + amt;
h.interestPostedTillDate = toDate;
return {
certNo: h.certNo,
instrumentType: h.instrumentType,
txnType: 'INTEREST',
voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`,
txnDate: toDate,
amount: amt,
particulars: `Interest calculated (accrual) for period ${fromDate} to ${toDate} - ${h.scriptName}`,
glCode,
glName
};
});
this.txns = [
...newTxns,
...this.txns
];
this.intCalcFromDate = toDate;
const total = newTxns.reduce((s, t) => s + t.amount, 0);
this.toast(
`Interest processed for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
);
}
// -------------------- Sell / Redeem Entry --------------------
+27 -472
View File
@@ -123,16 +123,6 @@ export interface InvestmentHolding {
* never re-counts days up to this date, so re-running a Calculation & Post for an overlapping
* or repeated period always yields 0 instead of double-posting the same interest. */
interestPostedTillDate?: string;
/** RBI investment category chosen at purchase — HTM / AFS / HFT. */
category?: string;
/** SGL / CSGL account the security is held in (custody, not an accounting GL). */
sglAccountNo?: string;
/** Backend id / deal number / party code once the purchase is saved (absent on sample rows). */
id?: number;
dealNo?: string;
partyCode?: string;
bookValue?: number;
settlementAmount?: number;
}
export interface InvestmentTxn {
@@ -258,80 +248,6 @@ export function accruedInterestBetween(h: InvestmentHolding, fromStr: string, to
return Math.round(h.faceValue * (h.interestRate / 100) * (days / 365.25));
}
/** Days between two DD-MM-YYYY dates on a 30/360 basis (every month 30 days), as the bank's existing
* CBS counts them — e.g. 10-08 → 30-09 = 50 days, 10-08 → 07-10 = 57 days. */
export function days360(fromStr: string, toStr: string): number {
const a = parseDDMMYYYY(fromStr);
const b = parseDDMMYYYY(toStr);
if (isNaN(a.getTime()) || isNaN(b.getTime()) || b <= a) {
return 0;
}
const d1 = Math.min(a.getDate(), 30);
const d2 = Math.min(b.getDate(), 30);
return (b.getFullYear() - a.getFullYear()) * 360 + (b.getMonth() - a.getMonth()) * 30 + (d2 - d1);
}
/** Most recent half-yearly coupon date on or before `onStr`, derived from the maturity date (coupons
* fall on the maturity day/month and six months either side of it). */
export function lastHalfYearlyCouponDate(maturityStr: string, onStr: string): string {
const mat = parseDDMMYYYY(maturityStr);
const on = parseDDMMYYYY(onStr);
if (isNaN(mat.getTime()) || isNaN(on.getTime())) {
return '';
}
const day = mat.getDate();
const couponOn = (year: number, month: number): Date => {
const lastDay = new Date(year, month + 1, 0).getDate();
return new Date(year, month, Math.min(day, lastDay));
};
const months = [
mat.getMonth() % 6,
(mat.getMonth() % 6) + 6
];
let best: Date | undefined;
for (const year of [
on.getFullYear() - 1,
on.getFullYear()
]) {
for (const m of months) {
const c = couponOn(year, m);
if (c <= on && (!best || c > best)) {
best = c;
}
}
}
return best ? formatDDMMYYYY(best) : '';
}
/** First half-yearly coupon date after `onStr` (coupons fall on the maturity day/month and six months
* either side), never later than maturity. */
export function nextHalfYearlyCouponDate(maturityStr: string, onStr: string): string {
const last = lastHalfYearlyCouponDate(maturityStr, onStr);
const mat = parseDDMMYYYY(maturityStr);
if (!last || isNaN(mat.getTime())) {
return '';
}
const l = parseDDMMYYYY(last);
const year = l.getFullYear() + (l.getMonth() >= 6 ? 1 : 0);
const month = (l.getMonth() + 6) % 12;
const next = new Date(year, month, Math.min(mat.getDate(), new Date(year, month + 1, 0).getDate()));
return formatDDMMYYYY(next > mat ? mat : next);
}
/** The two coupon days of a half-yearly security, e.g. "14 Feb / 14 Aug". */
export function couponDatesLabel(maturityStr: string): string {
const mat = parseDDMMYYYY(maturityStr);
if (isNaN(mat.getTime())) {
return '';
}
const label = (month: number): string => {
const day = Math.min(mat.getDate(), new Date(2001, month + 1, 0).getDate());
return `${day} ${new Date(2001, month, 1).toLocaleString('en-US', { month: 'short' })}`;
};
const first = mat.getMonth() % 6;
return `${label(first)} / ${label(first + 6)}`;
}
export function formatDDMMYYYY(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
}
@@ -1488,331 +1404,99 @@ export const CASH_BANK_GL_MASTER: InvestmentGlOption[] = [
{ glCode: '110030', glName: 'HDFC BANK CURRENT ACCOUNT' }
];
/** Investment GLs a script / party can be mapped to. PROPOSED codes awaiting approval — the
* backend will take these from the chart of accounts, never hard-coded. */
export const INVESTMENT_GL_OPTIONS: InvestmentGlOption[] = [
{ glCode: '100701', glName: 'INVESTMENT IN CENTRAL GOVT SECURITIES' },
{ glCode: '100702', glName: 'INVESTMENT IN STATE GOVT SECURITIES' },
{ glCode: '100703', glName: 'INVESTMENT IN BONDS / DEBENTURES' },
{ glCode: '100704', glName: 'FIXED DEPOSITS WITH OTHER BANKS' },
{ glCode: '100705', glName: 'INVESTMENT IN MUTUAL FUNDS' },
{ glCode: '100706', glName: 'INVESTMENT IN SHARES' }
];
/** Fallback investment GL when a script has none mapped. */
export const INVESTMENT_GL_BY_TYPE: Record<InstrumentType, string> = {
GOVT_SECURITY: '100701',
NON_SLR: '100703',
BOND: '100703',
FD: '100704',
MUTUAL_FUND: '100705',
SHARE: '100706'
};
/** Broken-period interest paid to the seller at purchase — an expense, not part of cost (RBI UCB Investment
* Directions para 152). */
export const BROKEN_PERIOD_INTEREST_GL: InvestmentGlOption = {
glCode: '500606',
glName: 'BROKEN PERIOD INTEREST PAID ON INVESTMENTS'
};
/** Charges on an investment deal — expenses on their own GLs, not part of the book value (Jitendra Sir). */
export const STAMP_DUTY_GL: InvestmentGlOption = { glCode: '500601', glName: 'STAMP DUTY ON INVESTMENTS' };
export const BROKERAGE_GL: InvestmentGlOption = { glCode: '500602', glName: 'BROKERAGE ON INVESTMENTS' };
export const OTHER_CHARGES_GL: InvestmentGlOption = { glCode: '500603', glName: 'OTHER CHARGES ON INVESTMENTS' };
/** Interest receivable (the senior's "Provision A/c") — debited for interest bought from the seller. */
export const INTEREST_RECEIVABLE_GL: InvestmentGlOption = {
glCode: '100603',
glName: 'INTEREST RECEIVABLE ON INVESTMENTS (PROVISION)'
};
/** Bank accounts a purchase can be paid from — these GLs already exist in the chart of accounts. */
export const SETTLEMENT_BANK_GL_OPTIONS: InvestmentGlOption[] = [
{ glCode: '100201', glName: 'IDBI BANK' },
{ glCode: '100202', glName: 'YES BANK' }
];
/** Every GL the Investment screens can show by name. */
export const INVESTMENT_GL_LOOKUP: InvestmentGlOption[] = [
...INVESTMENT_GL_OPTIONS,
INTEREST_RECEIVABLE_GL,
...SETTLEMENT_BANK_GL_OPTIONS
];
/** SLR classification on the Script Master, as in the bank's existing CBS ("GOI" = Central Govt). */
export type SlrType = 'GOI' | 'STATE_GOVT' | 'TREASURY' | 'NON_SLR';
export const SLR_TYPE_OPTIONS: Array<{ value: SlrType; label: string }> = [
{ value: 'GOI', label: 'GOI' },
{ value: 'STATE_GOVT', label: 'STATE GOVT' },
{ value: 'TREASURY', label: 'TREASURY' },
{ value: 'NON_SLR', label: 'NON SLR' }
];
export interface ScriptMaster {
/** Backend id once the script is saved (absent on the sample rows). */
id?: number;
scriptCode: string;
scriptName: string;
instrumentType: InstrumentType;
isin: string;
faceValuePerUnit: number;
// Fields below follow the bank's existing CBS Script Master; a purchase picks them up instead
// of the dealer re-typing them on every deal.
slrType?: SlrType;
/** Investment GL this script is booked to. */
glAccountId?: number;
glCode?: string;
glName?: string;
active?: boolean;
/** Interest (coupon) rate % per annum. */
couponRate?: number;
issueDate?: string;
maturityDate?: string;
// Issuer details — used for Bond / Mutual Fund scripts.
companyName?: string;
address1?: string;
address2?: string;
address3?: string;
city?: string;
pincode?: string;
phone?: string;
fax?: string;
corpCode?: string;
}
/** Period between two DD-MM-YYYY dates as years / months / days, as shown on the Script Master. */
export function periodBetween(fromStr?: string, toStr?: string): { years: number; months: number; days: number } {
const none = { years: 0, months: 0, days: 0 };
if (!fromStr || !toStr) {
return none;
}
const from = parseDDMMYYYY(fromStr);
const to = parseDDMMYYYY(toStr);
if (isNaN(from.getTime()) || isNaN(to.getTime()) || to < from) {
return none;
}
let years = to.getFullYear() - from.getFullYear();
let months = to.getMonth() - from.getMonth();
let days = to.getDate() - from.getDate();
if (days < 0) {
months -= 1;
days += new Date(to.getFullYear(), to.getMonth(), 0).getDate();
}
if (months < 0) {
years -= 1;
months += 12;
}
return { years, months, days };
}
export const SCRIPT_MASTER: ScriptMaster[] = [
{
scriptCode: 'FD-CUM',
scriptName: 'Cumulative Fixed Deposit',
instrumentType: 'FD',
isin: '',
faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100704'
},
{ scriptCode: 'FD-CUM', scriptName: 'Cumulative Fixed Deposit', instrumentType: 'FD', isin: '', faceValuePerUnit: 1 },
{
scriptCode: 'FD-MIP',
scriptName: 'Monthly Interest Payout FD',
instrumentType: 'FD',
isin: '',
faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100704'
faceValuePerUnit: 1
},
{
scriptCode: 'IN0020230012',
scriptName: '7.26% GOI 2033',
instrumentType: 'GOVT_SECURITY',
isin: 'IN0020230012',
faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 7.26,
issueDate: '15-01-2023',
maturityDate: '15-01-2033'
faceValuePerUnit: 100
},
{
scriptCode: 'IN0020220089',
scriptName: '7.10% GOI 2029',
instrumentType: 'GOVT_SECURITY',
isin: 'IN0020220089',
faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 7.1,
issueDate: '10-03-2022',
maturityDate: '10-03-2029'
},
{
scriptCode: 'IN0020210056',
scriptName: '6.79% GOI 2027',
instrumentType: 'GOVT_SECURITY',
isin: 'IN0020210056',
faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 6.79,
issueDate: '05-06-2021',
maturityDate: '05-06-2027'
},
{
scriptCode: 'IN0020240034',
scriptName: '7.18% GOI 2037',
instrumentType: 'GOVT_SECURITY',
isin: 'IN0020240034',
faceValuePerUnit: 100,
slrType: 'GOI',
glCode: '100701',
couponRate: 7.18,
issueDate: '22-04-2024',
maturityDate: '22-04-2037'
},
{
scriptCode: 'MH-SDL-2031',
scriptName: '8.05% Maharashtra SDL 2031',
instrumentType: 'GOVT_SECURITY',
isin: '',
faceValuePerUnit: 100,
slrType: 'STATE_GOVT',
glCode: '100702',
couponRate: 8.05,
issueDate: '15-02-2021',
maturityDate: '15-02-2031'
},
{
scriptCode: 'GJ-SDL-2026',
scriptName: '7.85% Gujarat SDL 2026',
instrumentType: 'GOVT_SECURITY',
isin: '',
faceValuePerUnit: 100,
slrType: 'STATE_GOVT',
glCode: '100702',
couponRate: 7.85,
issueDate: '18-09-2016',
maturityDate: '18-09-2026'
faceValuePerUnit: 100
},
{
scriptCode: 'TBILL-364D',
scriptName: '364 Days Treasury Bill',
instrumentType: 'GOVT_SECURITY',
isin: '',
faceValuePerUnit: 100,
slrType: 'TREASURY',
glCode: '100701',
couponRate: 0,
issueDate: '02-07-2026',
maturityDate: '02-07-2027'
},
{
scriptCode: 'TBILL-91D',
scriptName: '91 Days Treasury Bill',
instrumentType: 'GOVT_SECURITY',
isin: '',
faceValuePerUnit: 100,
slrType: 'TREASURY',
glCode: '100701',
couponRate: 0,
issueDate: '15-05-2026',
maturityDate: '13-08-2026'
faceValuePerUnit: 100
},
{
scriptCode: 'REC-7.5-2031',
scriptName: 'REC Ltd Bond 7.50% 2031',
instrumentType: 'NON_SLR',
isin: 'INE020B08JN0',
faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 7.5,
issueDate: '11-08-2021',
maturityDate: '11-08-2031',
companyName: 'REC Limited',
city: 'New Delhi'
faceValuePerUnit: 100
},
{
scriptCode: 'NABARD-7.35-2030',
scriptName: 'NABARD Bond 7.35% 2030',
instrumentType: 'NON_SLR',
isin: 'INE261F09EU1',
faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 7.35,
issueDate: '20-09-2020',
maturityDate: '20-09-2030',
companyName: 'NABARD',
city: 'Mumbai'
faceValuePerUnit: 100
},
{
scriptCode: 'LTF-8.1-2028',
scriptName: 'L&T Finance NCD 8.10% 2028',
instrumentType: 'BOND',
isin: 'INE027E07BR5',
faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 8.1,
companyName: 'L&T Finance Ltd',
city: 'Mumbai'
faceValuePerUnit: 100
},
{
scriptCode: 'BAF-7.95-2027',
scriptName: 'Bajaj Finance NCD 7.95% 2027',
instrumentType: 'BOND',
isin: 'INE296A07SP0',
faceValuePerUnit: 100,
slrType: 'NON_SLR',
glCode: '100703',
couponRate: 7.95,
companyName: 'Bajaj Finance Ltd',
city: 'Pune'
faceValuePerUnit: 100
},
{
scriptCode: 'SBI-OVN',
scriptName: 'SBI Overnight Fund',
instrumentType: 'MUTUAL_FUND',
isin: 'INF200KA1Q40',
faceValuePerUnit: 1000,
slrType: 'NON_SLR',
glCode: '100705',
companyName: 'SBI Mutual Fund',
city: 'Mumbai'
faceValuePerUnit: 1000
},
{
scriptCode: 'HDFC-LIQ',
scriptName: 'HDFC Liquid Fund',
instrumentType: 'MUTUAL_FUND',
isin: 'INF179KA1WR9',
faceValuePerUnit: 1000,
slrType: 'NON_SLR',
glCode: '100705',
companyName: 'HDFC Mutual Fund',
city: 'Mumbai'
faceValuePerUnit: 1000
},
{
scriptCode: 'SBIN',
scriptName: 'State Bank of India',
instrumentType: 'SHARE',
isin: 'INE062A01020',
faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100706'
faceValuePerUnit: 1
},
{
scriptCode: 'HDFCBANK',
scriptName: 'HDFC Bank Ltd',
instrumentType: 'SHARE',
isin: 'INE040A01034',
faceValuePerUnit: 1,
slrType: 'NON_SLR',
glCode: '100706'
faceValuePerUnit: 1
}
];
@@ -1877,150 +1561,23 @@ export const BROKER_MASTER: BrokerMaster[] = [
];
export interface PartyMaster {
/** Backend id once the party is saved (absent on the sample rows). */
id?: number;
partyCode: string;
partyName: string;
partyType: 'BANK' | 'CORPORATE' | 'GOVT' | 'AMC' | 'EXCHANGE' | 'COOP_SOCIETY';
panNo?: string;
// Fields below follow the bank's existing CBS Bank / Party Master.
bondType?: InstrumentType;
address1?: string;
address2?: string;
address3?: string;
city?: string;
pincode?: string;
phone?: string;
fax?: string;
bankType?: string;
/** GL this party is mapped to. */
glAccountId?: number;
glCode?: string;
glName?: string;
rbiCode?: string;
/** Exposure limit for this party (₹). */
limit?: number | null;
leiNo?: string;
// Counterparty's bank details
cpBankName?: string;
cpBranchName?: string;
cpAccountNo?: string;
cpIfsc?: string;
/** Absent means ACTIVE. An INACTIVE party is not offered for new deals. */
status?: 'ACTIVE' | 'INACTIVE';
panNo: string;
}
/** Sample Bank Type options until the bank confirms its list. */
export const BANK_TYPE_OPTIONS = [
'SCHEDULED COMMERCIAL BANK',
'STATE CO-OPERATIVE BANK',
'DISTRICT CENTRAL CO-OPERATIVE BANK',
'URBAN CO-OPERATIVE BANK',
'RBI / GOVERNMENT',
'OTHER'
];
export const PARTY_MASTER: PartyMaster[] = [
{
partyCode: 'PTY-001',
partyName: 'Jila Sahakari Kendriya Bank',
partyType: 'BANK',
bondType: 'FD',
glCode: '100704',
city: 'Vidisha',
bankType: 'DISTRICT CENTRAL CO-OPERATIVE BANK',
status: 'ACTIVE'
},
{
partyCode: 'PTY-002',
partyName: 'State Cooperative Bank Ltd',
partyType: 'BANK',
bondType: 'FD',
glCode: '100704',
city: 'Bhopal',
bankType: 'STATE CO-OPERATIVE BANK',
status: 'ACTIVE'
},
{
partyCode: 'PTY-003',
partyName: 'RBI - SGL',
partyType: 'GOVT',
bondType: 'GOVT_SECURITY',
glCode: '100701',
city: 'Mumbai',
bankType: 'RBI / GOVERNMENT',
status: 'ACTIVE'
},
{
partyCode: 'PTY-004',
partyName: 'REC Limited',
partyType: 'CORPORATE',
bondType: 'NON_SLR',
glCode: '100703',
city: 'New Delhi',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-005',
partyName: 'NABARD',
partyType: 'CORPORATE',
bondType: 'NON_SLR',
glCode: '100703',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-006',
partyName: 'L&T Finance Ltd',
partyType: 'CORPORATE',
bondType: 'BOND',
glCode: '100703',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-007',
partyName: 'SBI Mutual Fund',
partyType: 'AMC',
bondType: 'MUTUAL_FUND',
glCode: '100705',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-008',
partyName: 'HDFC Mutual Fund',
partyType: 'AMC',
bondType: 'MUTUAL_FUND',
glCode: '100705',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-009',
partyName: 'NSE/BSE',
partyType: 'EXCHANGE',
bondType: 'SHARE',
glCode: '100706',
city: 'Mumbai',
bankType: 'OTHER',
status: 'ACTIVE'
},
{
partyCode: 'PTY-010',
partyName: 'District Cooperative Bank',
partyType: 'COOP_SOCIETY',
bondType: 'SHARE',
glCode: '100706',
city: 'Vidisha',
bankType: 'DISTRICT CENTRAL CO-OPERATIVE BANK',
status: 'ACTIVE'
}
{ partyCode: 'PTY-001', partyName: 'Jila Sahakari Kendriya Bank', partyType: 'BANK', panNo: 'AABCJ1234K' },
{ partyCode: 'PTY-002', partyName: 'State Cooperative Bank Ltd', partyType: 'BANK', panNo: 'AABCS5678L' },
{ partyCode: 'PTY-003', partyName: 'RBI - SGL', partyType: 'GOVT', panNo: 'AAACR4849R' },
{ partyCode: 'PTY-004', partyName: 'REC Limited', partyType: 'CORPORATE', panNo: 'AAACR0641G' },
{ partyCode: 'PTY-005', partyName: 'NABARD', partyType: 'CORPORATE', panNo: 'AAACN0272B' },
{ partyCode: 'PTY-006', partyName: 'L&T Finance Ltd', partyType: 'CORPORATE', panNo: 'AAACL0140P' },
{ partyCode: 'PTY-007', partyName: 'SBI Mutual Fund', partyType: 'AMC', panNo: 'AAATS4325L' },
{ partyCode: 'PTY-008', partyName: 'HDFC Mutual Fund', partyType: 'AMC', panNo: 'AAATH1234M' },
{ partyCode: 'PTY-009', partyName: 'NSE/BSE', partyType: 'EXCHANGE', panNo: 'AAACN0028G' },
{ partyCode: 'PTY-010', partyName: 'District Cooperative Bank', partyType: 'COOP_SOCIETY', panNo: 'AABCD9988K' }
];
export interface SubTypeMaster {
@@ -2168,15 +1725,13 @@ export const LIEN_REASON_MASTER: LienReason[] = [
/** Daily Script Master (legacy menu "09") — the day's quoted rate per script, entered/imported
* every working day. Distinct from Script Market Detail ("13") which also carries YTM/valuation. */
export interface DailyScriptRate {
/** Backend id once saved. */
id?: number;
rateDate: string;
scriptCode: string;
scriptName: string;
openRate: number | null;
openRate: number;
closeRate: number;
highRate: number | null;
lowRate: number | null;
highRate: number;
lowRate: number;
}
export const DAILY_SCRIPT_RATES: DailyScriptRate[] = [
@@ -106,112 +106,38 @@
</button>
</div>
<!-- PARTY MASTER (layout follows the bank's existing CBS Bank / Party Master) -->
<!-- PARTY MASTER -->
@if (activeTab === 'party') {
<div class="pg-card">
<div class="pos-section-label">Bank / Party Master — Entry</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>* Bond Type</span>
<select [(ngModel)]="partyForm.bondType">
<option [ngValue]="undefined">-- Select --</option>
@for (t of instrumentTypes; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>* Party Code</span><input type="text" [value]="partyForm.partyCode || nextPartyCode" readonly
/></label>
<label class="inv-form-field"
><span>* Party Name</span><input type="text" [(ngModel)]="partyForm.partyName"
/></label>
<label class="inv-form-field"
><span>Address Line 1 (Building / Flat No.)</span
><input type="text" placeholder="e.g. Shop No. 12, Shanti Complex" [(ngModel)]="partyForm.address1"
/></label>
<label class="inv-form-field"
><span>Address Line 2 (Street / Area)</span
><input type="text" placeholder="e.g. MG Road, Near Bus Stand" [(ngModel)]="partyForm.address2"
/></label>
<label class="inv-form-field"
><span>Address Line 3 (Locality / Landmark)</span
><input type="text" placeholder="e.g. Arera Colony" [(ngModel)]="partyForm.address3"
/></label>
<label class="inv-form-field"><span>City</span><input type="text" [(ngModel)]="partyForm.city" /></label>
<label class="inv-form-field"
><span>Pincode</span><input type="text" maxlength="6" [(ngModel)]="partyForm.pincode"
/></label>
<label class="inv-form-field"><span>Phone</span><input type="text" [(ngModel)]="partyForm.phone" /></label>
<label class="inv-form-field"><span>Fax</span><input type="text" [(ngModel)]="partyForm.fax" /></label>
<label class="inv-form-field">
<span>Bank Type</span>
<select [(ngModel)]="partyForm.bankType">
<option [ngValue]="undefined">-- Select --</option>
@for (b of bankTypeOptions; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>* GL A/c No.</span>
<select [(ngModel)]="partyForm.glAccountId">
<option [ngValue]="undefined">-- Select --</option>
@for (g of investmentGlOptions; track g.id) {
<option [ngValue]="g.id">{{ g.glCode }} — {{ g.name }}</option>
}
</select>
</label>
<label class="inv-form-field"><span>RBI Code</span><input type="text" [(ngModel)]="partyForm.rbiCode" /></label>
<label class="inv-form-field"
><span>Limit (₹)</span><input type="number" min="0" [(ngModel)]="partyForm.limit"
/></label>
<label class="inv-form-field"><span>LEI No</span><input type="text" [(ngModel)]="partyForm.leiNo" /></label>
<label class="inv-form-field">
<span>Status</span>
<select [(ngModel)]="partyForm.status">
<option [ngValue]="undefined">ACTIVE</option>
<option value="INACTIVE">INACTIVE</option>
</select>
</label>
</div>
<div class="pos-section-label">Counterparty's Bank Details</div>
<div class="pos-section-label">Add Bank / Party</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Name of Bank</span><input type="text" [(ngModel)]="partyForm.cpBankName"
/></label>
<label class="inv-form-field"
><span>Name of Branch</span><input type="text" [(ngModel)]="partyForm.cpBranchName"
/></label>
<label class="inv-form-field"
><span>Account No.</span><input type="text" [(ngModel)]="partyForm.cpAccountNo"
/></label>
<label class="inv-form-field"
><span>IFSC</span><input type="text" maxlength="11" [(ngModel)]="partyForm.cpIfsc"
><span>Party Name *</span><input type="text" [(ngModel)]="newParty.partyName"
/></label>
<label class="inv-form-field">
<span>Party Type *</span>
<select [(ngModel)]="newParty.partyType">
<option [ngValue]="undefined">-- Select --</option>
<option value="BANK">BANK</option>
<option value="CORPORATE">CORPORATE</option>
<option value="GOVT">GOVT</option>
<option value="AMC">AMC</option>
<option value="EXCHANGE">EXCHANGE</option>
<option value="COOP_SOCIETY">COOP SOCIETY</option>
</select>
</label>
<label class="inv-form-field"><span>PAN No</span><input type="text" [(ngModel)]="newParty.panNo" /></label>
</div>
<div class="inv-form-actions">
<label class="inv-form-field"
><span>Search Party Code</span><input type="text" placeholder="e.g. PTY-001" [(ngModel)]="partySearchCode"
/></label>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="saving" (click)="saveParty()">Save</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="searchParty()">Search</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelParty()">Cancel</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropParty()">Drop</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelParty()">Exit</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="addParty()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Party Code</th>
<th>Bond Type</th>
<th>Party Name</th>
<th>City</th>
<th>Bank Type</th>
<th>GL A/c</th>
<th class="num">Limit</th>
<th>Counterparty Bank / IFSC</th>
<th>Status</th>
<th>Type</th>
<th>PAN No</th>
</tr>
</thead>
<tbody>
@@ -220,14 +146,9 @@
<td>
<span class="pg-chip">{{ p.partyCode }}</span>
</td>
<td>{{ p.bondType || '—' }}</td>
<td>{{ p.partyName }}</td>
<td>{{ p.city || '—' }}</td>
<td>{{ p.bankType || '—' }}</td>
<td>{{ p.glCode ? p.glCode + ' — ' + glName(p.glCode) : '—' }}</td>
<td class="num">{{ (p.limit ?? null) !== null ? '₹' + (p.limit | number: '1.0-0') : '—' }}</td>
<td>{{ p.cpBankName ? p.cpBankName + (p.cpIfsc ? ' / ' + p.cpIfsc : '') : '—' }}</td>
<td>{{ p.status ?? 'ACTIVE' }}</td>
<td>{{ p.partyType }}</td>
<td>{{ p.panNo }}</td>
</tr>
}
</tbody>
@@ -235,118 +156,42 @@
</div>
}
<!-- SCRIPT MASTER (layout follows the bank's existing CBS Script Master) -->
<!-- SCRIPT MASTER -->
@if (activeTab === 'script') {
<div class="pg-card">
<div class="pos-section-label">Script Master — Entry</div>
<div class="pos-section-label">Add Script</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Script Code</span><input type="text" [(ngModel)]="newScript.scriptCode"
/></label>
<label class="inv-form-field"
><span>Script Name *</span><input type="text" [(ngModel)]="newScript.scriptName"
/></label>
<label class="inv-form-field">
<span>* Bond Type</span>
<select [(ngModel)]="scriptForm.instrumentType" [disabled]="!!scriptForm.scriptCode">
<span>Instrument Type *</span>
<select [(ngModel)]="newScript.instrumentType">
<option [ngValue]="undefined">-- Select --</option>
@for (t of instrumentTypes; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>SLR</span>
<select [(ngModel)]="scriptForm.slrType">
<option [ngValue]="undefined">-- Select --</option>
@for (o of slrTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</label>
<label class="inv-form-field"><span>ISIN</span><input type="text" [(ngModel)]="newScript.isin" /></label>
<label class="inv-form-field"
><span>* Script Code</span><input type="text" [value]="scriptForm.scriptCode || nextScriptCode" readonly
/></label>
<label class="inv-form-field"
><span>* Script Desc</span
><input type="text" placeholder="e.g. 7.50%GOI2034" [(ngModel)]="scriptForm.scriptName"
/></label>
<label class="inv-form-field">
<span>* GL A/c No.</span>
<select [(ngModel)]="scriptForm.glAccountId">
<option [ngValue]="undefined">-- Select --</option>
@for (g of investmentGlOptions; track g.id) {
<option [ngValue]="g.id">{{ g.glCode }} — {{ g.name }}</option>
}
</select>
</label>
<label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}ISIN</span><input type="text" [(ngModel)]="scriptForm.isin"
/></label>
<label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Int Rate %</span
><input type="number" step="0.01" min="0" [(ngModel)]="scriptForm.couponRate"
/></label>
<label class="inv-form-field"
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Issue Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="scriptForm.issueDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Redum / Mat Date</span
><mifosx-date-ddmmyyyy [(ngModel)]="scriptForm.maturityDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>Period (Year)</span><input type="text" [value]="scriptPeriod.years" readonly
/></label>
<label class="inv-form-field"
><span>Period (Month)</span><input type="text" [value]="scriptPeriod.months" readonly
/></label>
<label class="inv-form-field"
><span>Period (Days)</span><input type="text" [value]="scriptPeriod.days" readonly
/></label>
</div>
<div class="pos-section-label">Company Details (Bond / Mutual Fund)</div>
<div class="inv-form-grid">
<label class="inv-form-field"
><span>Company Name</span><input type="text" [(ngModel)]="scriptForm.companyName"
/></label>
<label class="inv-form-field"
><span>Address Line 1 (Building / Flat No.)</span
><input type="text" placeholder="e.g. Shop No. 12, Shanti Complex" [(ngModel)]="scriptForm.address1"
/></label>
<label class="inv-form-field"
><span>Address Line 2 (Street / Area)</span
><input type="text" placeholder="e.g. MG Road, Near Bus Stand" [(ngModel)]="scriptForm.address2"
/></label>
<label class="inv-form-field"
><span>Address Line 3 (Locality / Landmark)</span
><input type="text" placeholder="e.g. Arera Colony" [(ngModel)]="scriptForm.address3"
/></label>
<label class="inv-form-field"><span>City</span><input type="text" [(ngModel)]="scriptForm.city" /></label>
<label class="inv-form-field"
><span>Pincode</span><input type="text" maxlength="6" [(ngModel)]="scriptForm.pincode"
/></label>
<label class="inv-form-field"><span>Phone</span><input type="text" [(ngModel)]="scriptForm.phone" /></label>
<label class="inv-form-field"><span>Fax</span><input type="text" [(ngModel)]="scriptForm.fax" /></label>
<label class="inv-form-field"
><span>Corp. Code</span><input type="text" [(ngModel)]="scriptForm.corpCode"
><span>Face Value / Unit</span><input type="number" [(ngModel)]="newScript.faceValuePerUnit"
/></label>
</div>
<div class="inv-form-actions">
<label class="inv-form-field"
><span>Search Script Code</span><input type="text" placeholder="e.g. G00001" [(ngModel)]="scriptSearchCode"
/></label>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="saving" (click)="saveScript()">Save</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="searchScript()">Search</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelScript()">Cancel</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelScript()">Exit</button>
<button type="button" class="pg-btn pg-btn--primary" (click)="addScript()">Save</button>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Script Code</th>
<th>Bond Type</th>
<th>SLR</th>
<th>Script Desc</th>
<th>GL A/c</th>
<th>Script Name</th>
<th>Instrument</th>
<th>ISIN</th>
<th class="num">Int Rate %</th>
<th>Issue Date</th>
<th>Maturity</th>
<th class="num">Face Value/Unit</th>
</tr>
</thead>
<tbody>
@@ -355,14 +200,10 @@
<td>
<span class="pg-chip">{{ s.scriptCode }}</span>
</td>
<td>{{ s.instrumentType }}</td>
<td>{{ s.slrType || '—' }}</td>
<td>{{ s.scriptName }}</td>
<td>{{ s.glCode || '—' }}</td>
<td>{{ s.instrumentType }}</td>
<td>{{ s.isin || '—' }}</td>
<td class="num">{{ s.couponRate ?? '—' }}</td>
<td>{{ s.issueDate || '—' }}</td>
<td>{{ s.maturityDate || '—' }}</td>
<td class="num">{{ s.faceValuePerUnit }}</td>
</tr>
}
</tbody>
@@ -545,11 +386,10 @@
<th class="num">High</th>
<th class="num">Low</th>
<th class="num">Close</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of dailyScriptRates; track r.id ?? r.rateDate + r.scriptCode) {
@for (r of dailyScriptRates; track r.rateDate + r.scriptCode) {
<tr>
<td>{{ r.rateDate }}</td>
<td>
@@ -560,13 +400,6 @@
<td class="num">{{ r.highRate | number: '1.2-2' }}</td>
<td class="num">{{ r.lowRate | number: '1.2-2' }}</td>
<td class="num">{{ r.closeRate | number: '1.2-2' }}</td>
<td>
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropDailyRate(r)">Drop</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="pg-empty">No rates entered yet.</td>
</tr>
}
</tbody>
@@ -6,17 +6,20 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import {
SCRIPT_MASTER,
BROKER_MASTER,
PARTY_MASTER,
SUBTYPE_MASTER,
CONTROL_PARAMETERS,
MUTUAL_FUND_PRODUCTS,
SGL_BANK_MASTER,
SCRIPT_MARKET_DETAIL,
DAILY_SCRIPT_RATES,
LIEN_REASON_MASTER,
ScriptMaster,
BrokerMaster,
@@ -28,14 +31,9 @@ import {
ScriptMarketDetail,
DailyScriptRate,
LienReason,
InstrumentType,
BANK_TYPE_OPTIONS,
SLR_TYPE_OPTIONS,
INVESTMENT_GL_LOOKUP,
periodBetween
InstrumentType
} from '../investment-data';
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
import { InvestmentGlAccount, InvestmentService, apiErrorMessage, toDailyScriptRate } from '../investment.service';
type MasterTab =
| 'party'
@@ -67,19 +65,18 @@ type MasterTab =
DateDdmmyyyyPickerComponent
]
})
export class InvestmentMastersComponent implements OnInit {
export class InvestmentMastersComponent {
activeTab: MasterTab = 'party';
scripts: ScriptMaster[] = [];
scripts: ScriptMaster[] = [...SCRIPT_MASTER];
brokers: BrokerMaster[] = [...BROKER_MASTER];
parties: PartyMaster[] = [];
parties: PartyMaster[] = [...PARTY_MASTER];
subTypes: SubTypeMaster[] = [...SUBTYPE_MASTER];
controlParams: ControlParameter[] = [...CONTROL_PARAMETERS];
mfProducts: MutualFundProductMaster[] = [...MUTUAL_FUND_PRODUCTS];
sglBanks: SglBankMaster[] = [...SGL_BANK_MASTER];
marketDetails: ScriptMarketDetail[] = [...SCRIPT_MARKET_DETAIL];
/** Loaded from the backend (Daily Script Master) in ngOnInit. */
dailyScriptRates: DailyScriptRate[] = [];
dailyScriptRates: DailyScriptRate[] = [...DAILY_SCRIPT_RATES];
lienReasons: LienReason[] = [...LIEN_REASON_MASTER];
readonly instrumentTypes: InstrumentType[] = [
@@ -109,19 +106,8 @@ export class InvestmentMastersComponent implements OnInit {
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
// Bank / Party Master and Script Master follow the bank's existing CBS screens (one entry form
// with Save / Search / Cancel / Drop / Exit) and are stored in the backend through
// InvestmentService; the other master tabs are still sample data.
private readonly investmentService = inject(InvestmentService);
partyForm: Partial<PartyMaster> = {};
partySearchCode = '';
scriptForm: Partial<ScriptMaster> = {};
scriptSearchCode = '';
saving = false;
readonly bankTypeOptions = BANK_TYPE_OPTIONS;
readonly slrTypeOptions = SLR_TYPE_OPTIONS;
/** Enabled ASSET detail GLs from the chart of accounts. */
investmentGlOptions: InvestmentGlAccount[] = [];
newParty: Partial<PartyMaster> = {};
newScript: Partial<ScriptMaster> = {};
newSubType: Partial<SubTypeMaster> = {};
newBroker: Partial<BrokerMaster> = {};
newControlParam: Partial<ControlParameter> = {};
@@ -130,217 +116,43 @@ export class InvestmentMastersComponent implements OnInit {
newSglBank: Partial<SglBankMaster> = {};
newDailyRate: Partial<DailyScriptRate> = {};
ngOnInit(): void {
this.loadDailyRates();
this.investmentService.getAssetGlAccounts().subscribe({
next: (gls) => (this.investmentGlOptions = gls),
error: (err) => this.toast(`Could not load GL accounts: ${apiErrorMessage(err)}`)
});
this.reloadMasters();
}
setTab(tab: MasterTab): void {
this.activeTab = tab;
}
/** Re-reads both masters from the backend (also refreshes the lists the Purchase screen uses). */
private reloadMasters(): void {
this.investmentService.syncSharedMasters().subscribe({
next: ({ parties, scripts }) => {
this.parties = parties;
this.scripts = scripts;
},
error: (err) => this.toast(`Could not load masters: ${apiErrorMessage(err)}`)
});
}
glName(glCode?: string): string {
return (
this.investmentGlOptions.find((g) => g.glCode === glCode)?.name ??
INVESTMENT_GL_LOOKUP.find((g) => g.glCode === glCode)?.glName ??
''
);
}
// -------------------- Bank / Party Master --------------------
/** Preview of the code the backend will assign; the saved code always comes from the backend. */
get nextPartyCode(): string {
const nums = this.parties.map((p) => parseInt(p.partyCode.replace(/\D/g, ''), 10)).filter((n) => !isNaN(n));
return `PTY-${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(3, '0')}`;
}
saveParty(): void {
const f = this.partyForm;
if (!f.bondType || !f.partyName || !f.glAccountId) {
this.toast('Enter Bond Type, Party Name and GL A/c No.');
addParty(): void {
if (!this.newParty.partyName || !this.newParty.partyType) {
this.toast('Enter Party Name and Type.');
return;
}
const body = {
bondType: f.bondType,
partyName: f.partyName,
address1: f.address1 ?? '',
address2: f.address2 ?? '',
address3: f.address3 ?? '',
city: f.city ?? '',
pincode: f.pincode ?? '',
phone: f.phone ?? '',
fax: f.fax ?? '',
bankType: f.bankType ?? '',
glAccountId: Number(f.glAccountId),
rbiCode: f.rbiCode ?? '',
limit: f.limit ?? null,
leiNo: f.leiNo ?? '',
cpBankName: f.cpBankName ?? '',
cpBranchName: f.cpBranchName ?? '',
cpAccountNo: f.cpAccountNo ?? '',
cpIfsc: f.cpIfsc ?? '',
active: f.status !== 'INACTIVE',
locale: 'en'
};
const request = f.id ? this.investmentService.updateParty(f.id, body) : this.investmentService.createParty(body);
this.saving = true;
request.subscribe({
next: (res) => {
this.saving = false;
this.toast(`Party ${res.resourceIdentifier ?? f.partyCode} ${f.id ? 'updated' : 'saved'}.`);
this.partyForm = {};
this.reloadMasters();
},
error: (err) => {
this.saving = false;
this.toast(apiErrorMessage(err));
}
});
this.parties = [
{
partyCode: `PTY-${String(this.parties.length + 1).padStart(3, '0')}`,
panNo: '',
...this.newParty
} as PartyMaster,
...this.parties
];
this.newParty = {};
this.toast('Party/Bank master added.');
}
searchParty(): void {
const p = this.parties.find((x) => x.partyCode.toUpperCase() === this.partySearchCode.trim().toUpperCase());
if (!p) {
this.toast(`Party Code ${this.partySearchCode} not found.`);
addScript(): void {
if (!this.newScript.scriptName || !this.newScript.instrumentType) {
this.toast('Enter Script Name and Instrument Type.');
return;
}
this.partyForm = { ...p };
this.toast(`Party ${p.partyCode} loaded.`);
}
cancelParty(): void {
this.partyForm = {};
this.partySearchCode = '';
}
dropParty(): void {
const f = this.partyForm;
if (!f.id) {
this.toast('Search a saved party first, then Drop.');
return;
}
this.investmentService.deleteParty(f.id).subscribe({
next: () => {
this.toast(`Party ${f.partyCode} dropped.`);
this.partyForm = {};
this.reloadMasters();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
// -------------------- Script Master --------------------
/** Coupon / maturity terms only apply to debt securities. */
get scriptIsDebtSecurity(): boolean {
return [
'GOVT_SECURITY',
'NON_SLR',
'BOND'
].includes(this.scriptForm.instrumentType ?? '');
}
/** Preview of the code the backend will assign — a type letter + running number, e.g. G00001. */
get nextScriptCode(): string {
const prefixByType: Record<InstrumentType, string> = {
GOVT_SECURITY: 'G',
NON_SLR: 'N',
BOND: 'B',
MUTUAL_FUND: 'M',
FD: 'F',
SHARE: 'S'
};
const type = this.scriptForm.instrumentType;
if (!type) {
return '';
}
const prefix = prefixByType[type];
const pattern = new RegExp(`^${prefix}(\\d{5})$`);
const nums = this.scripts
.map((s) => pattern.exec(s.scriptCode)?.[1])
.filter((n): n is string => !!n)
.map((n) => parseInt(n, 10));
return `${prefix}${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(5, '0')}`;
}
get scriptPeriod(): { years: number; months: number; days: number } {
return periodBetween(this.scriptForm.issueDate, this.scriptForm.maturityDate);
}
saveScript(): void {
const f = this.scriptForm;
if (!f.instrumentType || !f.scriptName || !f.glAccountId) {
this.toast('Enter Bond Type, Script Desc and GL A/c No.');
return;
}
if (this.scriptIsDebtSecurity && (!f.isin || f.couponRate == null || !f.issueDate)) {
this.toast('Enter ISIN, Int Rate and Issue Date for this security.');
return;
}
const body = {
bondType: f.instrumentType,
slrType: f.slrType ?? '',
scriptDesc: f.scriptName,
glAccountId: Number(f.glAccountId),
isin: f.isin ?? '',
intRate: f.couponRate ?? null,
issueDate: f.issueDate ?? '',
maturityDate: f.maturityDate ?? '',
companyName: f.companyName ?? '',
address1: f.address1 ?? '',
address2: f.address2 ?? '',
address3: f.address3 ?? '',
city: f.city ?? '',
pincode: f.pincode ?? '',
phone: f.phone ?? '',
fax: f.fax ?? '',
corpCode: f.corpCode ?? '',
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
const request = f.id ? this.investmentService.updateScript(f.id, body) : this.investmentService.createScript(body);
this.saving = true;
request.subscribe({
next: (res) => {
this.saving = false;
this.toast(`Script ${res.resourceIdentifier ?? f.scriptCode} ${f.id ? 'updated' : 'saved'}.`);
this.scriptForm = {};
this.reloadMasters();
},
error: (err) => {
this.saving = false;
this.toast(apiErrorMessage(err));
}
});
}
searchScript(): void {
const s = this.scripts.find((x) => x.scriptCode.toUpperCase() === this.scriptSearchCode.trim().toUpperCase());
if (!s) {
this.toast(`Script Code ${this.scriptSearchCode} not found.`);
return;
}
this.scriptForm = { ...s };
this.toast(`Script ${s.scriptCode} loaded.`);
}
cancelScript(): void {
this.scriptForm = {};
this.scriptSearchCode = '';
this.scripts = [
{
scriptCode: this.newScript.scriptCode || `SCR-${this.scripts.length + 1}`,
isin: '',
faceValuePerUnit: 100,
...this.newScript
} as ScriptMaster,
...this.scripts
];
this.newScript = {};
this.toast('Script master added.');
}
addSubType(): void {
@@ -436,56 +248,24 @@ export class InvestmentMastersComponent implements OnInit {
this.toast('SGL Bank master added.');
}
private loadDailyRates(): void {
this.investmentService.getDailyRates().subscribe({
next: (rows) => (this.dailyScriptRates = rows.map(toDailyScriptRate)),
error: (err) => this.toast(`Could not load daily script rates: ${apiErrorMessage(err)}`)
});
}
addDailyRate(): void {
const r = this.newDailyRate;
if (!r.scriptCode || !r.rateDate || r.closeRate == null) {
if (!this.newDailyRate.scriptCode || !this.newDailyRate.rateDate || this.newDailyRate.closeRate == null) {
this.toast('Enter Rate Date, Script Code and Close Rate.');
return;
}
const script = this.scripts.find((s) => s.scriptCode === r.scriptCode);
if (!script?.id) {
this.toast(`Script ${r.scriptCode} is not saved in Script Master.`);
return;
}
const body = {
scriptId: script.id,
rateDate: r.rateDate,
closeRate: r.closeRate,
...(r.openRate != null ? { openRate: r.openRate } : {}),
...(r.highRate != null ? { highRate: r.highRate } : {}),
...(r.lowRate != null ? { lowRate: r.lowRate } : {}),
dateFormat: 'dd-MM-yyyy',
locale: 'en'
};
this.investmentService.createDailyRate(body).subscribe({
next: () => {
this.toast(`Rate for ${r.scriptCode} on ${r.rateDate} saved.`);
this.newDailyRate = {};
this.loadDailyRates();
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
/** A wrong rate is corrected by dropping it and entering it again. */
dropDailyRate(r: DailyScriptRate): void {
if (!r.id) {
return;
}
this.investmentService.deleteDailyRate(r.id).subscribe({
next: () => {
this.toast(`Rate for ${r.scriptCode} on ${r.rateDate} dropped.`);
this.loadDailyRates();
},
error: (err) => this.toast(apiErrorMessage(err))
});
const script = this.scripts.find((s) => s.scriptCode === this.newDailyRate.scriptCode);
this.dailyScriptRates = [
{
scriptName: script?.scriptName || '',
openRate: this.newDailyRate.closeRate,
highRate: this.newDailyRate.closeRate,
lowRate: this.newDailyRate.closeRate,
...this.newDailyRate
} as DailyScriptRate,
...this.dailyScriptRates
];
this.newDailyRate = {};
this.toast('Daily script rate added.');
}
// -------------------- Export to Excel --------------------
@@ -499,121 +279,46 @@ export class InvestmentMastersComponent implements OnInit {
return one(
[
'Party Code',
'Bond Type',
'Party Name',
'Address',
'City',
'Pincode',
'Phone',
'Fax',
'Bank Type',
'GL A/c No',
'RBI Code',
'Limit',
'LEI No',
'Counterparty Bank',
'Branch',
'Account No',
'IFSC',
'Status'
'Type',
'PAN No'
],
[
12,
14,
30,
36,
14,
10,
14,
14,
24,
12,
12,
14,
22,
24,
20,
18,
14,
10
16,
14
],
this.parties.map((p) => [
p.partyCode,
p.bondType ?? '',
p.partyName,
[
p.address1,
p.address2,
p.address3
]
.filter((x) => !!x)
.join(', '),
p.city ?? '',
p.pincode ?? '',
p.phone ?? '',
p.fax ?? '',
p.bankType ?? '',
p.glCode ?? '',
p.rbiCode ?? '',
p.limit ?? null,
p.leiNo ?? '',
p.cpBankName ?? '',
p.cpBranchName ?? '',
p.cpAccountNo ?? '',
p.cpIfsc ?? '',
p.status ?? 'ACTIVE'
p.partyType,
p.panNo
])
);
case 'script':
return one(
[
'Script Code',
'Bond Type',
'SLR',
'Script Desc',
'GL A/c No',
'Script Name',
'Instrument',
'ISIN',
'Int Rate %',
'Issue Date',
'Maturity Date',
'Period (Y/M/D)',
'Face Value/Unit',
'Company Name',
'Corp Code'
'Face Value/Unit'
],
[
14,
16,
12,
30,
12,
16,
10,
14,
14,
14,
14,
24,
12
16,
16
],
this.scripts.map((s) => {
const p = periodBetween(s.issueDate, s.maturityDate);
return [
s.scriptCode,
s.instrumentType,
s.slrType ?? '',
s.scriptName,
s.glCode ?? '',
s.isin,
s.couponRate ?? null,
s.issueDate ?? '',
s.maturityDate ?? '',
s.issueDate && s.maturityDate ? `${p.years}/${p.months}/${p.days}` : '',
s.faceValuePerUnit,
s.companyName ?? '',
s.corpCode ?? ''
];
})
this.scripts.map((s) => [
s.scriptCode,
s.scriptName,
s.instrumentType,
s.isin,
s.faceValuePerUnit
])
);
case 'subtype':
return one(
-394
View File
@@ -1,394 +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/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, forkJoin } from 'rxjs';
import { map, tap } from 'rxjs/operators';
import {
DailyScriptRate,
InstrumentType,
InvestmentHolding,
PARTY_MASTER,
PartyMaster,
SCRIPT_MASTER,
ScriptMaster,
SlrType
} from './investment-data';
/** Investment party as returned by /investment/parties. */
export interface InvestmentPartyDto {
id: number;
partyCode: string;
bondType: InstrumentType;
partyName: string;
address1?: string;
address2?: string;
address3?: string;
city?: string;
pincode?: string;
phone?: string;
fax?: string;
bankType?: string;
glAccountId: number;
glCode: string;
glName: string;
rbiCode?: string;
limit?: number;
leiNo?: string;
cpBankName?: string;
cpBranchName?: string;
cpAccountNo?: string;
cpIfsc?: string;
active: boolean;
}
/** Investment script as returned by /investment/scripts (dates are DD-MM-YYYY). */
export interface InvestmentScriptDto {
id: number;
scriptCode: string;
bondType: InstrumentType;
slrType?: SlrType;
scriptDesc: string;
glAccountId: number;
glCode: string;
glName: string;
isin?: string;
faceValuePerUnit: number;
intRate?: number;
issueDate?: string;
maturityDate?: string;
companyName?: string;
address1?: string;
address2?: string;
address3?: string;
city?: string;
pincode?: string;
phone?: string;
fax?: string;
corpCode?: string;
active: boolean;
}
/** Purchase entry (holding) as returned by /investment/purchases (dates are DD-MM-YYYY). */
export interface InvestmentPurchaseDto {
id: number;
dealNo: string;
certNo: string;
bondType: InstrumentType;
scriptId: number;
scriptCode: string;
scriptDesc: string;
isin?: string;
partyId: number;
partyCode: string;
partyName: string;
dealDate: string;
dealTime?: string;
settlementType: string;
settlementDate: string;
faceValue: number;
purchaseRate: number;
units: number;
balanceUnits: number;
brokerage?: number;
stampDuty?: number;
otherCharges?: number;
bookValue: number;
premiumDiscount?: number;
intRate?: number;
issueDate?: string;
maturityDate?: string;
lastIntDate?: string;
accruedDays?: number;
accruedInterest?: number;
settlementAmount: number;
ytm?: number;
category: string;
subType?: string;
investmentGlAccountId: number;
investmentGlCode: string;
investmentGlName: string;
crGlAccountId: number;
crGlCode: string;
crGlName: string;
otherDetails?: string;
status: string;
}
export interface InvestmentGlAccount {
id: number;
glCode: string;
name: string;
}
/**
* Backend calls for the Investment masters (Bank / Party Master and Script Master). Results are also copied into the
* shared PARTY_MASTER / SCRIPT_MASTER lists so screens that still read those lists (Purchase Entry) offer the saved
* masters.
*/
@Injectable({ providedIn: 'root' })
export class InvestmentService {
private http = inject(HttpClient);
// -------------------- Bank / Party Master --------------------
getParties(): Observable<InvestmentPartyDto[]> {
return this.http.get<InvestmentPartyDto[]>('/investment/parties');
}
createParty(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/parties', body);
}
updateParty(id: number, body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.put<{ resourceId: number; resourceIdentifier: string }>(`/investment/parties/${id}`, body);
}
deleteParty(id: number): Observable<unknown> {
return this.http.delete(`/investment/parties/${id}`);
}
// -------------------- Script Master --------------------
getScripts(): Observable<InvestmentScriptDto[]> {
return this.http.get<InvestmentScriptDto[]>('/investment/scripts');
}
createScript(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/scripts', body);
}
updateScript(id: number, body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.put<{ resourceId: number; resourceIdentifier: string }>(`/investment/scripts/${id}`, body);
}
// -------------------- Purchase Entry --------------------
getPurchases(): Observable<InvestmentPurchaseDto[]> {
return this.http.get<InvestmentPurchaseDto[]>('/investment/purchases');
}
/** Coupon and 31 Mar / 30 Sep provision lines of a holding up to maturity (calculated, not posted). */
getInterestSchedule(purchaseId: number, asOnDate?: string): Observable<InvestmentInterestScheduleDto> {
const params = asOnDate ? `?asOnDate=${encodeURIComponent(asOnDate)}` : '';
return this.http.get<InvestmentInterestScheduleDto>(
`/investment/purchases/${purchaseId}/interest-schedule${params}`
);
}
// -------------------- Daily Script Master --------------------
getDailyRates(scriptId?: number): Observable<InvestmentDailyRateDto[]> {
const params = scriptId ? `?scriptId=${scriptId}` : '';
return this.http.get<InvestmentDailyRateDto[]>(`/investment/daily-rates${params}`);
}
/** Newest Daily Script Master rate of a script on or before a date; null when none has been entered. */
getLatestDailyRate(scriptId: number, onDate: string): Observable<InvestmentDailyRateDto | null> {
return this.http.get<InvestmentDailyRateDto | null>(
`/investment/daily-rates/latest?scriptId=${scriptId}&onDate=${encodeURIComponent(onDate)}`
);
}
createDailyRate(body: object): Observable<{ resourceId: number }> {
return this.http.post<{ resourceId: number }>('/investment/daily-rates', body);
}
deleteDailyRate(rateId: number): Observable<unknown> {
return this.http.delete(`/investment/daily-rates/${rateId}`);
}
createPurchase(body: object): Observable<{ resourceId: number; resourceIdentifier: string }> {
return this.http.post<{ resourceId: number; resourceIdentifier: string }>('/investment/purchases', body);
}
deletePurchase(id: number): Observable<unknown> {
return this.http.delete(`/investment/purchases/${id}`);
}
// -------------------- GL accounts --------------------
/** Enabled ASSET detail GLs — the only kind an investment party / script can be booked to. */
getAssetGlAccounts(): Observable<InvestmentGlAccount[]> {
return this.http
.get<
Array<{ id: number; glCode: string; name: string; disabled: boolean }>
>('/glaccounts', { params: { type: '1', usage: '1', disabled: 'false' } })
.pipe(
map((list) =>
list
.map((g) => ({ id: g.id, glCode: g.glCode, name: g.name }))
.sort((a, b) => a.glCode.localeCompare(b.glCode))
)
);
}
/** Loads both masters from the backend and refreshes the shared lists other screens read. */
syncSharedMasters(): Observable<{ parties: PartyMaster[]; scripts: ScriptMaster[] }> {
return forkJoin({ parties: this.getParties(), scripts: this.getScripts() }).pipe(
map(({ parties, scripts }) => ({ parties: parties.map(toPartyMaster), scripts: scripts.map(toScriptMaster) })),
tap(({ parties, scripts }) => {
PARTY_MASTER.splice(0, PARTY_MASTER.length, ...parties);
SCRIPT_MASTER.splice(0, SCRIPT_MASTER.length, ...scripts);
})
);
}
}
export function toPartyMaster(p: InvestmentPartyDto): PartyMaster {
return {
id: p.id,
partyCode: p.partyCode,
partyName: p.partyName,
partyType: 'BANK',
bondType: p.bondType,
address1: p.address1,
address2: p.address2,
address3: p.address3,
city: p.city,
pincode: p.pincode,
phone: p.phone,
fax: p.fax,
bankType: p.bankType,
glAccountId: p.glAccountId,
glCode: p.glCode,
glName: p.glName,
rbiCode: p.rbiCode,
limit: p.limit ?? null,
leiNo: p.leiNo,
cpBankName: p.cpBankName,
cpBranchName: p.cpBranchName,
cpAccountNo: p.cpAccountNo,
cpIfsc: p.cpIfsc,
status: p.active ? 'ACTIVE' : 'INACTIVE'
};
}
export function toScriptMaster(s: InvestmentScriptDto): ScriptMaster {
return {
id: s.id,
scriptCode: s.scriptCode,
scriptName: s.scriptDesc,
instrumentType: s.bondType,
isin: s.isin ?? '',
faceValuePerUnit: s.faceValuePerUnit,
slrType: s.slrType,
glAccountId: s.glAccountId,
glCode: s.glCode,
glName: s.glName,
couponRate: s.intRate,
issueDate: s.issueDate,
maturityDate: s.maturityDate,
companyName: s.companyName,
address1: s.address1,
address2: s.address2,
address3: s.address3,
city: s.city,
pincode: s.pincode,
phone: s.phone,
fax: s.fax,
corpCode: s.corpCode,
active: s.active
};
}
/** A saved purchase as a row of the Holdings register. */
export function toHolding(p: InvestmentPurchaseDto): InvestmentHolding {
return {
id: p.id,
dealNo: p.dealNo,
certNo: p.certNo,
instrumentType: p.bondType,
scriptCode: p.scriptCode,
scriptName: p.scriptDesc,
party: p.partyName,
partyCode: p.partyCode,
subType: p.subType ?? '',
faceValue: p.faceValue,
purchaseRate: p.purchaseRate,
units: p.balanceUnits,
purchaseDate: p.settlementDate,
maturityDate: p.maturityDate || p.dealDate,
interestRate: p.intRate ?? 0,
interestFreq: p.bondType === 'MUTUAL_FUND' ? 'CUMULATIVE' : 'HALF_YEARLY',
glCode: p.investmentGlCode,
glName: p.investmentGlName,
crToAc: p.crGlCode,
brokerCode: '',
status: 'ACTIVE',
category: p.category,
bookValue: p.bookValue,
settlementAmount: p.settlementAmount
};
}
/** One line of a holding's interest schedule (see InvestmentInterestScheduleData in the backend). */
export interface InvestmentInterestRowDto {
type: 'COUPON' | 'PROVISION';
intDate: string;
fromDate: string;
toDate: string;
days: number;
grossInterest: number;
alreadyBooked: number;
amount: number;
due: boolean;
}
export interface InvestmentInterestScheduleDto {
purchaseId: number;
dealNo: string;
certNo: string;
scriptCode: string;
scriptDesc: string;
faceValue: number;
unitValue: number;
balanceUnits: number;
purchaseRate: number;
bookValue: number;
intRate: number;
dealDate: string;
lastIntDate: string;
maturityDate: string;
purchasedAccruedInterest: number;
couponPerHalfYear: number;
asOnDate: string;
interestGlCode: string;
interestGlName: string;
rows: InvestmentInterestRowDto[];
}
/** A Daily Script Master row (see InvestmentDailyRateData in the backend). */
export interface InvestmentDailyRateDto {
id: number;
scriptId: number;
scriptCode: string;
scriptDesc: string;
bondType: string;
rateDate: string;
openRate: number | null;
highRate: number | null;
lowRate: number | null;
closeRate: number;
}
export function toDailyScriptRate(d: InvestmentDailyRateDto): DailyScriptRate {
return {
id: d.id,
rateDate: d.rateDate,
scriptCode: d.scriptCode,
scriptName: d.scriptDesc,
openRate: d.openRate,
closeRate: d.closeRate,
highRate: d.highRate,
lowRate: d.lowRate
};
}
/** The first validation / domain-rule message from a Fineract error response, for a toast. */
export function apiErrorMessage(err: unknown): string {
const e = err as HttpErrorResponse;
const body = e?.error;
return body?.errors?.[0]?.defaultUserMessage || body?.defaultUserMessage || e?.message || 'Request failed.';
}
@@ -230,11 +230,29 @@
</tr>
</thead>
<tbody>
@for (mapping of selection[role.configKey]; track $index; let i = $index) {
<!-- Tracked by the row's own id, never by position. With $index, deleting a row shifted every
row after it up one, Angular reused the inputs bound to the old positions, and
ngModel wrote their values back into the shortened array - the row reappeared and
Save stayed grey. -->
@for (mapping of selection[role.configKey]; track mapping.rowId; 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">
<!-- The chart runs to a hundred accounts, so the list is typed into rather
than scrolled. Cleared as the dropdown opens, so each one opens whole. -->
<mat-select
[(ngModel)]="mapping.glAccountId"
[disabled]="savingKey === role.configKey"
(openedChange)="accountSearch = ''"
>
<mat-option>
<ngx-mat-select-search
[(ngModel)]="accountSearch"
[ngModelOptions]="{ standalone: true }"
placeholderLabel="Search by GL code or name"
noEntriesFoundLabel="No account matches"
></ngx-mat-select-search>
</mat-option>
@for (o of availableAccounts(role, i); track o.id) {
<mat-option [value]="o.id">{{ o.glCode }} · {{ o.name }}</mat-option>
}
@@ -7,8 +7,9 @@
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { catchError, forkJoin, of } from 'rxjs';
import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from '../organization.service';
@@ -20,6 +21,15 @@ export interface ReserveMapping {
glAccountId: number;
weightPercentage: number;
liabilityClass: string | null;
/**
* Identity for the row, so the template can track rows by it rather than by position.
*
* Tracking by index made deletion impossible: removing a row shifts every row after it up one, so
* Angular reused the inputs that were bound to the old positions and ngModel wrote their values
* straight back into the shortened array. The row reappeared, the draft matched what was stored
* again, and Save stayed grey. Never sent to the backend.
*/
rowId: number;
}
/**
@@ -43,7 +53,9 @@ export interface ReserveMapping {
styleUrls: ['./rbi-reserve-config.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
FormsModule,
ReactiveFormsModule,
NgxMatSelectSearchModule
]
})
export class RbiReserveConfigComponent implements OnInit {
@@ -80,9 +92,33 @@ export class RbiReserveConfigComponent implements OnInit {
/** Every reserve role the backend returned, before the form filter narrows it. */
private allRoles: any[] = [];
/** Re-slices what is on screen. No round trip - the full set is already held. */
/** Hands each mapping row an identity the template can track it by. See ReserveMapping.rowId. */
private nextRowId = 1;
/**
* What is typed into the search box inside an open account dropdown.
*
* One field for the whole screen rather than one per row: only one dropdown is open at a time, and
* it is cleared as each one opens, so a term left behind cannot narrow the next list a user opens
* without them seeing why it is short.
*/
accountSearch = '';
/**
* Re-slices what is on screen. No round trip - the full set is already held.
*
* The rows are rebuilt for whatever the new slice brings in. They used not to be: selection was
* filled once, for the heads the first slice happened to contain, so switching the filter showed
* the newly admitted heads with no rows at all - a card with a name and an empty body, reading as
* a head that had nothing mapped when it was simply never read.
*/
onFormFilterChange(): void {
this.glRoles = this.rolesForScreen();
for (const role of this.glRoles) {
if (!this.selection[role.configKey]) {
this.reloadSelection(role);
}
}
this.rebuildGroups();
}
@@ -230,11 +266,7 @@ export class RbiReserveConfigComponent implements OnInit {
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.reloadSelection(role);
}
this.rebuildGroups();
}
@@ -288,9 +320,16 @@ export class RbiReserveConfigComponent implements OnInit {
);
}
/** A role with nothing mapped makes its return refuse to compute — surfaced, not hidden. */
/**
* A role with nothing mapped makes its return refuse to compute — surfaced, not hidden.
*
* Read from what is STORED, not from the draft on screen. The returns run off the saved mapping,
* so a row cleared but not yet saved changes nothing for them; counting it as unmapped made the
* banner announce that returns could not compute while they were still computing perfectly well.
* The unsaved edit is already shown by Save turning active.
*/
isUnmapped(role: any): boolean {
return !(this.selection[role.configKey] || []).length;
return !(role.accounts || []).length;
}
get unmappedCount(): number {
@@ -327,11 +366,29 @@ export class RbiReserveConfigComponent implements OnInit {
// 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 }];
{
glAccountId: null as any,
weightPercentage: 100,
liabilityClass: role.classified ? 'TIME' : null,
rowId: this.nextRowId++
}];
}
/**
* Drops a row and saves the head straight away.
*
* The bin used to clear the row on screen and leave it to Save, which read as the mapping having
* been removed when nothing had reached the server - the row came back on the next refresh. A bin
* is understood to delete, so it deletes: a row with nothing chosen in it never existed on the
* server and only has to go from the screen, and anything else is written through at once.
*/
removeMapping(role: any, index: number): void {
this.selection[role.configKey] = (this.selection[role.configKey] || []).filter((_, i) => i !== index);
const rows = this.selection[role.configKey] || [];
const removed = rows[index];
this.selection[role.configKey] = rows.filter((_, i) => i !== index);
if (removed?.glAccountId) {
this.saveRole(role);
}
}
/**
@@ -343,7 +400,23 @@ export class RbiReserveConfigComponent implements OnInit {
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 ?? ''}`));
const free = this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`));
// Typed into the search box above the list. Matched on code AND name, because an administrator
// looking for a head knows one or the other - the GL code off a trial balance, or the name off
// the balance sheet - and should not have to know which this list is sorted by.
const term = (this.accountSearch || '').trim().toLowerCase();
if (!term) {
return free;
}
// The chosen account stays in the list whatever is typed. Dropping it would leave mat-select
// with a value that matches no option, and the row would read as empty.
return free.filter(
(o) =>
o.id === current?.glAccountId ||
`${o.glCode}`.toLowerCase().includes(term) ||
`${o.name}`.toLowerCase().includes(term)
);
}
/** A row that cannot be saved - no account chosen, or a portion outside 0-100. */
@@ -379,6 +452,16 @@ export class RbiReserveConfigComponent implements OnInit {
.filter((e) => Math.abs(e.total - 100) > 0.0001);
}
/** Rebuilds one head's rows from what the server last returned, discarding the draft. */
private reloadSelection(role: any): void {
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),
rowId: this.nextRowId++
}));
}
saveRole(role: any): void {
this.savingKey = role.configKey;
this.errorMsg = '';
@@ -398,6 +481,11 @@ export class RbiReserveConfigComponent implements OnInit {
catchError((err: any) => {
this.savingKey = null;
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.';
// Put the screen back to what the server holds. A failed save that left the draft showing
// would say the change had gone through while the server still had the old mapping - and
// the bin deletes immediately, so this is the only thing between a rejected delete and a
// row that looks gone but is not.
this.reloadSelection(role);
return of(null);
})
)
+1 -1
View File
@@ -62,7 +62,7 @@ export class AccountsFilterPipe implements PipeTransform {
account.status.code === 'savingsAccountStatusType.rejected'
);
});
} else {
} else if (status !== 'all') {
accounts = accounts.filter((account: any) => {
return (
account.status.code !== 'savingsAccountStatusType.withdrawn.by.applicant' &&
@@ -89,7 +89,18 @@
<p class="base-rate-remarks">{{ activeBaseVersion.remarks }}</p>
}
<table class="base-rate-table" mat-table [dataSource]="activeBaseVersion.slabs">
<p class="base-rate-remarks">
RD tenure is counted in months, from {{ rdChart.minTenureMonths ?? '-' }} months (the Minimum Tenure on the
Terms tab). A rate below the first slab comes from the RD CBS base chart; the rest from this chart.
</p>
@if (rdChart.shortTermRateMissing) {
<p class="base-rate-remarks">
The RD CBS base chart has no rate for {{ configuredMinTenureMonths }} months, so this product can only be
opened from {{ rdChart.minTenureMonths }} months.
</p>
}
<table class="base-rate-table" mat-table [dataSource]="activeBaseSlabs">
<ng-container matColumnDef="srNo">
<th mat-header-cell *matHeaderCellDef>Sr.</th>
<td mat-cell *matCellDef="let slab">{{ slab.srNo }}</td>
@@ -35,6 +35,8 @@ import { ProductsService } from 'app/products/products.service';
import { SettingsService } from 'app/settings/settings.service';
import { Accounting } from 'app/core/utils/accounting';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { buildRdChart, RdChart, tenureInMonths } from '../rd-product-chart';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RecurringDepositProductPreviewStepComponent } from '../recurring-deposit-product-stepper/recurring-deposit-product-preview-step/recurring-deposit-product-preview-step.component';
import { MatTabsModule } from '@angular/material/tabs';
@@ -74,6 +76,7 @@ export class CreateRecurringDepositProductComponent {
private settingsService = inject(SettingsService);
private accounting = inject(Accounting);
private dateUtils = inject(Dates);
private alertService = inject(AlertService);
@ViewChild(RecurringDepositProductDetailsStepComponent, { static: true })
recurringDepositProductDetailsStep: RecurringDepositProductDetailsStepComponent;
@@ -92,6 +95,9 @@ export class CreateRecurringDepositProductComponent {
accountingRuleData: string[] = [];
selectedTabIndex = 0;
rdInterestRates: any[] = [];
rdBaseSlabs: any[] = [];
private rdChartCache: { baseSlabs: any[]; rdBaseSlabs: any[]; minTenureMonths: number | null; chart: RdChart } | null =
null;
readonly rdSlabColumns = [
'srNo',
'description',
@@ -122,6 +128,17 @@ export class CreateRecurringDepositProductComponent {
this.rdInterestRates = [];
}
});
// The RD base chart supplies the rate for 6 months to below 1 year, which the FD chart does not cover
this.productsService.getRdBaseInterestRates().subscribe({
next: (rates: any) => {
const versions: any[] = Array.isArray(rates) ? rates : rates ? [rates] : [];
const active = versions.find((version: any) => version.isActive) ?? versions[0];
this.rdBaseSlabs = active?.slabs || [];
},
error: () => {
this.rdBaseSlabs = [];
}
});
}
get recurringDepositProductDetailsForm() {
@@ -167,11 +184,37 @@ export class CreateRecurringDepositProductComponent {
return null;
}
/** The product's chart in months, from 6 months up - see rd-product-chart.ts. */
/** The minimum tenure entered on the Terms tab, in months. */
get configuredMinTenureMonths(): number | null {
const terms = this.recurringDepositProductTermsForm?.value;
return tenureInMonths(terms?.minDepositTerm, terms?.minDepositTermTypeId);
}
get rdChart(): RdChart {
const baseSlabs = this.activeBaseVersion?.slabs || [];
const minTenureMonths = this.configuredMinTenureMonths;
if (
this.rdChartCache?.baseSlabs !== baseSlabs ||
this.rdChartCache?.rdBaseSlabs !== this.rdBaseSlabs ||
this.rdChartCache?.minTenureMonths !== minTenureMonths
) {
this.rdChartCache = {
baseSlabs,
rdBaseSlabs: this.rdBaseSlabs,
minTenureMonths,
chart: buildRdChart(baseSlabs, this.rdBaseSlabs, minTenureMonths)
};
}
return this.rdChartCache.chart;
}
get activeBaseSlabs() {
return this.activeBaseVersion?.slabs || [];
return this.rdChart.slabs;
}
get recurringDepositProduct() {
const rdChart = this.rdChart;
return {
...this.recurringDepositProductDetailsStep.recurringDepositProductDetails,
...this.recurringDepositProductCurrencyStep.recurringDepositProductCurrency,
@@ -179,14 +222,20 @@ export class CreateRecurringDepositProductComponent {
...this.recurringDepositProductSettingsStep.recurringDepositProductSettings,
...this.recurringDepositProductChargesStep.recurringDepositProductCharges,
...this.recurringDepositProductAccountingStep.recurringDepositProductAccounting,
// The minimum comes from the Terms tab. Only when the chart has no rate that short is it raised to where the
// chart's rates begin, so no account can be opened for a tenure the chart cannot price.
...(rdChart.minTenureMonths != null && rdChart.minTenureMonths !== this.configuredMinTenureMonths
? { minDepositTerm: rdChart.minTenureMonths, minDepositTermTypeId: 2 }
: {}),
charts: this.recurringDepositProductCharts,
rdSlabs: this.activeBaseSlabs
rdSlabs: rdChart.slabs
};
}
get recurringDepositProductCharts() {
const activeBaseVersion = this.activeBaseVersion;
if (!activeBaseVersion?.slabs?.length) {
const slabs = this.rdChart.slabs;
if (!activeBaseVersion || !slabs.length) {
return [];
}
const dateFormat = this.settingsService.dateFormat;
@@ -208,7 +257,7 @@ export class CreateRecurringDepositProductComponent {
isPrimaryGroupingByAmount: false,
locale: this.settingsService.language.code,
dateFormat,
chartSlabs: activeBaseVersion.slabs.map((slab: any) => {
chartSlabs: slabs.map((slab: any) => {
const chartSlab: any = {
periodType: slab.periodTypeEnum,
fromPeriod: slab.fromPeriod,
@@ -232,7 +281,28 @@ export class CreateRecurringDepositProductComponent {
] as any[];
}
/**
* Terms (min/max installment) and Settings (lien percentage) have rules of their own that the Next buttons do not
* enforce. Opens the first tab that breaks one, with its errors shown, instead of submitting.
*/
private focusFirstInvalidStep(): boolean {
const steps = [
{ form: this.recurringDepositProductTermsForm, tab: 2 },
{ form: this.recurringDepositProductSettingsForm, tab: 3 }
];
const invalid = steps.find((step) => step.form?.invalid);
if (!invalid) {
return false;
}
invalid.form.markAllAsTouched();
this.selectedTabIndex = invalid.tab;
return true;
}
submit() {
if (this.focusFirstInvalidStep()) {
return;
}
const recurringDepositProduct: any = this.pruneEmptyValues({
...this.recurringDepositProduct,
charts: this.recurringDepositProductCharts,
@@ -243,14 +313,24 @@ export class CreateRecurringDepositProductComponent {
recurringDepositProduct.description = '';
}
delete recurringDepositProduct.advancedAccountingRules;
this.productsService.createRecurringDepositProduct(recurringDepositProduct).subscribe((response: any) => {
this.router.navigate(
[
'../',
response.resourceId
],
{ relativeTo: this.route }
);
this.productsService.createRecurringDepositProduct(recurringDepositProduct).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Recurring deposit product creation request submitted successfully for approval.'
});
this.router.navigate(['../'], { relativeTo: this.route });
} else {
this.router.navigate(
[
'../',
response.resourceId
],
{ relativeTo: this.route }
);
}
}
});
}
@@ -241,7 +241,28 @@ export class EditRecurringDepositProductComponent {
] as any[];
}
/**
* Terms (min/max installment) and Settings (lien percentage) have rules of their own that the Next buttons do not
* enforce. Opens the first tab that breaks one, with its errors shown, instead of submitting.
*/
private focusFirstInvalidStep(): boolean {
const steps = [
{ form: this.recurringDepositProductTermsForm, tab: 2 },
{ form: this.recurringDepositProductSettingsForm, tab: 3 }
];
const invalid = steps.find((step) => step.form?.invalid);
if (!invalid) {
return false;
}
invalid.form.markAllAsTouched();
this.selectedTabIndex = invalid.tab;
return true;
}
submit() {
if (this.focusFirstInvalidStep()) {
return;
}
const recurringDepositProduct: any = this.pruneEmptyValues({
...this.recurringDepositProduct,
charges: (this.recurringDepositProduct.charges ?? []).map((charge: any) => ({ id: charge.id })),
@@ -0,0 +1,207 @@
/**
* 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/.
*/
/**
* Builds a recurring deposit product's interest chart in months.
*
* RD products take their rates from the FD "DOUBLE" CBS base chart, which is kept in years and starts at 1 year, so
* on its own it leaves every tenure under 12 months without a rate. When the product's minimum tenure is shorter than
* that, the band from the minimum up to just below the first slab takes its rate from the RD CBS base chart - the slab
* covering the shortest possible span of that many months.
*
* Every slab is expressed in months because the backend matches a tenure to a slab by counting whole units of the
* slab's own period type: a 30-month RD is "2 years" against a year slab, so "1-2 Years" really covers 12-35 months.
* Writing that out as months keeps the chart identical in meaning while letting a single chart hold the sub-year band.
*/
/** SavingsPeriodFrequencyType ids. */
const MONTHS_PERIOD_TYPE = 2;
const YEARS_PERIOD_TYPE = 3;
export interface RdChartSlab {
srNo: number;
description: string;
periodTypeEnum: number;
periodTypeLabel: string;
fromPeriod: number;
toPeriod: number | null;
annualInterestRate: number;
seniorCitizenRate?: number;
amountRangeFrom?: number;
amountRangeTo?: number;
}
export interface RdChart {
slabs: RdChartSlab[];
/** Shortest tenure, in months, the product opens - its minimum deposit term. */
minTenureMonths: number | null;
/** Set when the RD base chart has no rate for the minimum tenure, so the product can only start at its first slab. */
shortTermRateMissing: boolean;
}
/** A tenure given as a count of months or years (SavingsPeriodFrequencyType id), in months. */
export function tenureInMonths(term: number | string | null | undefined, termTypeId: number | null | undefined): number | null {
const value = Number(term);
if (term === null || term === undefined || term === '' || !Number.isFinite(value) || value <= 0) {
return null;
}
return termTypeId === YEARS_PERIOD_TYPE ? value * 12 : value;
}
function unitInMonths(slab: any): number | null {
const label = String(slab.periodTypeLabel ?? slab.periodType?.value ?? '').toLowerCase();
if (label.startsWith('year')) {
return 12;
}
if (label.startsWith('month')) {
return 1;
}
return null;
}
function inDays(slab: any, period: number): number | null {
const label = String(slab.periodTypeLabel ?? slab.periodType?.value ?? '').toLowerCase();
if (label.startsWith('day')) {
return period;
}
if (label.startsWith('week')) {
return period * 7;
}
if (label.startsWith('month')) {
return period * 30;
}
if (label.startsWith('year')) {
return period * 365;
}
return null;
}
/** The fewest calendar days any run of {@code months} months can span (6 months -> 181, January to July). */
function shortestSpanInDays(months: number): number {
let shortest = Infinity;
for (let start = 0; start < 12; start++) {
const from = Date.UTC(2025, start, 1);
const to = Date.UTC(2025, start + months, 1);
shortest = Math.min(shortest, Math.round((to - from) / 86400000));
}
return shortest;
}
/** The RD base chart rate for an RD of {@code months} months, or null when that chart has no slab covering it. */
function shortTermRate(rdBaseSlabs: any[], months: number): number | null {
const days = shortestSpanInDays(months);
const slab = (rdBaseSlabs || []).find((candidate: any) => {
const from = inDays(candidate, candidate.fromPeriod ?? 0);
const to = candidate.toPeriod == null ? Infinity : inDays(candidate, candidate.toPeriod);
return from != null && to != null && from <= days && days <= to;
});
return slab?.annualInterestRate ?? null;
}
/**
* Month-wise slabs from {@code fromMonths} to {@code toMonths}, each month priced from the RD base chart and runs of
* months at the same rate merged into one slab - so a 3-month minimum against a chart with day bands for 46-90,
* 91-180 and 181-364 days becomes 3, 4-5 and 6-11 months rather than one rate for the lot. Empty when the base chart
* has no rate for the first month; a later month without one carries the previous month's rate.
*/
function shortTermBands(rdBaseSlabs: any[], fromMonths: number, toMonths: number): RdChartSlab[] {
const bands: RdChartSlab[] = [];
let previousRate: number | null = null;
let bandStart = fromMonths;
for (let month = fromMonths; month <= toMonths; month++) {
const rate = shortTermRate(rdBaseSlabs, month) ?? previousRate;
if (rate == null) {
return [];
}
const last = bands[bands.length - 1];
if (last && last.annualInterestRate === rate) {
last.toPeriod = month;
last.description = monthsLabel(bandStart, month);
} else {
bandStart = month;
bands.push({
srNo: 0,
description: monthsLabel(month, month),
periodTypeEnum: MONTHS_PERIOD_TYPE,
periodTypeLabel: 'Months',
fromPeriod: month,
toPeriod: month,
annualInterestRate: rate
});
}
previousRate = rate;
}
return bands;
}
function monthsLabel(from: number, to: number | null): string {
return to == null ? `${from}+ Months` : `${from} to ${to} Months`;
}
/**
* @param minTenureMonths the product's minimum tenure in months; null when not yet entered, in which case the chart
* simply starts at its first slab
*/
export function buildRdChart(baseSlabs: any[], rdBaseSlabs: any[], minTenureMonths: number | null): RdChart {
const slabs: RdChartSlab[] = [];
const sorted = [...(baseSlabs || [])].sort((a: any, b: any) => (a.fromPeriod ?? 0) - (b.fromPeriod ?? 0));
for (const slab of sorted) {
const unit = unitInMonths(slab);
if (unit == null) {
continue;
}
const from = (slab.fromPeriod ?? 0) * unit;
const to = slab.toPeriod == null ? null : (slab.toPeriod + 1) * unit - 1;
const converted: RdChartSlab = {
srNo: 0,
description: monthsLabel(from, to),
periodTypeEnum: MONTHS_PERIOD_TYPE,
periodTypeLabel: 'Months',
fromPeriod: from,
toPeriod: to,
annualInterestRate: slab.annualInterestRate
};
if (slab.seniorCitizenRate != null) {
converted.seniorCitizenRate = slab.seniorCitizenRate;
}
if (slab.amountRangeFrom != null && slab.amountRangeFrom !== '') {
converted.amountRangeFrom = slab.amountRangeFrom;
}
if (slab.amountRangeTo != null && slab.amountRangeTo !== '') {
converted.amountRangeTo = slab.amountRangeTo;
}
slabs.push(converted);
}
// The server only accepts a chart whose first slab starts at 0 or 1, so the chart always opens at 1 month and the
// product's minimum deposit term - not the chart - is what keeps an RD from being opened below the minimum.
let shortTermRateMissing = false;
let effectiveMinimum: number | null = null;
const firstFrom = slabs.length ? slabs[0].fromPeriod : null;
if (firstFrom != null) {
effectiveMinimum = minTenureMonths == null ? firstFrom : Math.max(minTenureMonths, 1);
if (firstFrom > 1) {
const needsShortTermBand = minTenureMonths != null && minTenureMonths < firstFrom;
const shortTermSlabs = needsShortTermBand ? shortTermBands(rdBaseSlabs, minTenureMonths, firstFrom - 1) : [];
if (shortTermSlabs.length) {
// The first band is opened down to 1 month for the server; its label still names the real minimum.
shortTermSlabs[0].fromPeriod = 1;
slabs.unshift(...shortTermSlabs);
} else {
// No rate below the first slab: stretch it down to 1 month to satisfy the server, but keep the minimum at
// where that slab really starts so nothing shorter can be opened at its rate.
shortTermRateMissing = needsShortTermBand;
slabs[0].fromPeriod = 1;
effectiveMinimum = Math.max(effectiveMinimum, firstFrom);
}
}
}
slabs.forEach((slab, index) => (slab.srNo = index + 1));
return { slabs, minTenureMonths: effectiveMinimum, shortTermRateMissing };
}
@@ -95,11 +95,14 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
otherGlMappingsMap: this.recurringDepositProductsTemplate?.otherGlMappingsMap
};
const accountingMappingOptions = this.recurringDepositProductsTemplate?.accountingMappingOptions || {};
// Defaults to Cash (2) for a brand-new product (no existing accountingRule yet), but preserves
// the product's own current accounting rule when editing - previously this was hardcoded to 2
// unconditionally, which silently forced every edited product onto Cash-based accounting (and
// its required GL mappings) even for products that were and should remain "None".
const accountingRuleId = this.recurringDepositProductsTemplate?.accountingRule?.id ?? 2;
// Cash (2) for a brand-new product, but the product's own current accounting rule when editing -
// previously this was hardcoded to 2 unconditionally, which silently forced every edited product
// onto Cash-based accounting (and its required GL mappings) even for products that were and
// should remain "None". Only an existing product (one with an id) has a rule of its own: the
// create template always reports accountingRule = None, so reading it there submitted every new
// product as None while this screen showed "Cash", and hid the GL mappings it needs.
const isEditing = !!this.recurringDepositProductsTemplate?.id;
const accountingRuleId = isEditing ? (this.recurringDepositProductsTemplate?.accountingRule?.id ?? 2) : 2;
this.assetAccountOptions = accountingMappingOptions.assetAccountOptions || [];
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
@@ -172,11 +175,20 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
if (this.isCashOrAccrualAccounting()) {
const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings;
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
this.setOtherGlMappings(
'liabilitiesOtherGlMappings',
glMappings?.liabilities || [],
// Interest Paid is a liability clearing account (Dr Payable / Cr Paid, then Dr Paid / Cr RD), saved under
// liabilities. Products saved before that kept it under expenses, so both groups are read.
const fromLiabilities = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.liabilities || [],
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.liabilityAccountOptions
),
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.liabilityAccountOptions
);
this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions);
const fromExpenses = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.expenses || [],
@@ -265,15 +277,15 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
const otherGlMappings = {
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
expenses: [
liabilities: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
this.expenseAccountOptions
this.liabilityAccountOptions
),
...map('expensesOtherGlMappings', this.expenseAccountOptions)
...map('liabilitiesOtherGlMappings', this.liabilityAccountOptions)
],
expenses: map('expensesOtherGlMappings', this.expenseAccountOptions),
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
};
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
@@ -312,6 +324,7 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
setRequired('incomeFromPenaltyAccountId', cashOrAccrual);
setRequired('feesReceivableAccountId', accrualOnly);
setRequired('penaltiesReceivableAccountId', accrualOnly);
// Required on accrual (the accrual job credits it); optional on cash, where it only routes the closure posting.
setRequired('interestPayableAccountId', accrualOnly);
setRequired('escheatLiabilityId', cashOrAccrual && this.isDormancyTrackingEnabled());
@@ -330,8 +343,8 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
rows: [
{
key: 'savingsReferenceAccountId',
label: 'Savings Reference Account',
inputLabel: 'Savings reference account',
label: 'Recurring Deposit Reference Account',
inputLabel: 'Recurring deposit reference account',
controlName: 'savingsReferenceAccountId',
options: this.assetAccountOptions,
required: true
@@ -364,8 +377,8 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
rows: [
{
key: 'savingsControlAccountId',
label: 'Savings Control Account',
inputLabel: 'Savings control account',
label: 'Recurring Deposit Control Account',
inputLabel: 'Recurring deposit control account',
controlName: 'savingsControlAccountId',
options: this.liabilityAccountOptions,
required: true
@@ -378,13 +391,22 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
options: this.liabilityAccountOptions,
required: true
},
// Offered on cash as well as accrual: the interest credited at maturity or premature closure is routed
// Dr Interest On Recurring Deposits / Cr Payable, Dr Payable / Cr Interest Paid, Dr Interest Paid / Cr
// the RD. Optional - a product naming neither posts straight to the control account as before.
{
key: 'interestPayableAccountId',
label: 'Interest Payable Account',
inputLabel: 'Interest payable account',
controlName: 'interestPayableAccountId',
options: this.liabilityAccountOptions,
accrualOnly: true
options: this.liabilityAccountOptions
},
{
key: 'savingsInterestPaidAccountId',
label: 'Recurring Deposit Interest Paid',
inputLabel: 'Recurring deposit interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.liabilityAccountOptions
},
{
key: 'escheatLiabilityId',
@@ -411,13 +433,6 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
},
{
key: 'savingsInterestPaidAccountId',
label: 'Recurring Deposit Interest Paid',
inputLabel: 'Recurring deposit interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.expenseAccountOptions
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
@@ -66,7 +66,10 @@
<div class="rdpc-alert">
<div class="rdpc-alert__content">
<h4>Nomination Facility</h4>
<p>Allow account holders to designate individuals to receive funds in the event of account holder's death.</p>
<p>
Yes: every RD under this product must have at least one nominee (100% share) before it can be opened. No:
nominees cannot be recorded on its RDs.
</p>
</div>
<mat-radio-group class="rdpc-alert__choices" formControlName="nominationFacility">
<mat-radio-button [value]="true">yes</mat-radio-button>
@@ -77,7 +80,11 @@
<div class="rdpc-alert">
<div class="rdpc-alert__content">
<h4>Auto Debit Facility</h4>
<p>Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.</p>
<p>
Yes: an RD can opt in to having each installment collected automatically on its due date from its linked
Savings account (needs Deposit Account Link). If the balance is short, the installment stays overdue and is
retried on the next run.
</p>
</div>
<mat-radio-group class="rdpc-alert__choices" formControlName="autoDebitFacility">
<mat-radio-button [value]="true">yes</mat-radio-button>
@@ -68,37 +68,41 @@
</div>
<div class="rdpd-grid rdpd-grid--classification">
<mat-form-field appearance="outline" class="rdpd-field">
<mat-form-field appearance="outline" class="rdpd-field" subscriptSizing="dynamic">
<mat-label>Account Type</mat-label>
<mat-select formControlName="accountType" required>
@for (accountType of accountTypeData; track accountType) {
<mat-option [value]="accountType">{{ accountType }}</mat-option>
}
</mat-select>
<mat-hint>Default account type pre-selected when an RD is opened under this product.</mat-hint>
<mat-error>Account Type <strong>is required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="rdpd-field">
<mat-form-field appearance="outline" class="rdpd-field" subscriptSizing="dynamic">
<mat-label>GL Account (Liability)</mat-label>
<mat-select formControlName="glAccountId" [compareWith]="compareById">
@for (account of liabilityAccountOptions; track account.id) {
<mat-option [value]="account.id">{{ account.name }} ({{ account.glCode }})</mat-option>
}
</mat-select>
<mat-hint>Its GL code forms part of every new RD account number. Postings use the Accounting tab.</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="rdpd-field" (click)="effectiveToPicker.open()">
<mat-form-field appearance="outline" class="rdpd-field" subscriptSizing="dynamic" (click)="effectiveToPicker.open()">
<mat-label>Effective To</mat-label>
<input matInput [matDatepicker]="effectiveToPicker" formControlName="effectiveTo" />
<mat-datepicker-toggle matSuffix [for]="effectiveToPicker"></mat-datepicker-toggle>
<mat-datepicker #effectiveToPicker></mat-datepicker>
<mat-hint>Last day new RDs can be opened. Existing RDs run to maturity.</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="rdpd-field" (click)="endDatePicker.open()">
<mat-form-field appearance="outline" class="rdpd-field" subscriptSizing="dynamic" (click)="endDatePicker.open()">
<mat-label>End Date</mat-label>
<input matInput [matDatepicker]="endDatePicker" formControlName="endDate" [min]="minEndDate" />
<mat-datepicker-toggle matSuffix [for]="endDatePicker"></mat-datepicker-toggle>
<mat-datepicker #endDatePicker></mat-datepicker>
<mat-hint>Product wind-up date. No RD may mature after it.</mat-hint>
@if (recurringDepositProductDetailsForm.get('endDate')?.hasError('matDatepickerMin')) {
<mat-error>End Date must be after Effective To date</mat-error>
}
@@ -115,6 +119,10 @@
<div class="rdpd-field rdpd-field--full rdpd-checkbox-row">
<mat-checkbox formControlName="shareLinkingEnabled">Deposit Account Link</mat-checkbox>
<span class="rdpd-checkbox-hint">
Every RD must be linked to the customer's Savings/Current account - the default account for maturity payout
and the account Auto Debit collects installments from.
</span>
</div>
</div>
</div>
@@ -76,6 +76,13 @@ $gray-900: #202124;
.rdpd-checkbox-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 12px;
min-height: 56px;
}
.rdpd-checkbox-hint {
font-size: 12px;
opacity: 0.7;
}
@@ -150,7 +150,7 @@
<mat-divider class="flex-100"></mat-divider>
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Nominal Annual Interest Rate' | translate }} (%):</span>
<span class="flex-40">Post-Maturity Interest Rate (%):</span>
<span class="flex-60">{{ recurringDepositProduct.nominalAnnualInterestRate | formatNumber }} %</span>
</div>
@@ -199,41 +199,11 @@
@if (recurringDepositProduct.effectiveDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Effective Date:</span>
<span class="flex-40">Product Effective From:</span>
<span class="flex-60">{{ recurringDepositProduct.effectiveDate | dateFormat }}</span>
</div>
}
@if (recurringDepositProduct.averagePeriod) {
<div class="flex-100 layout-row">
<span class="flex-40">Average Period:</span>
<span class="flex-60">{{ recurringDepositProduct.averagePeriod }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Auto Interest Post:</span>
<span class="flex-60">{{ recurringDepositProduct.autoPost | yesNo }}</span>
</div>
@if (
recurringDepositProduct.frequencyFixDay !== undefined &&
recurringDepositProduct.frequencyFixDay !== null &&
recurringDepositProduct.frequencyFixDay !== ''
) {
<div class="flex-100 layout-row">
<span class="flex-40">Frequency (Fix Day):</span>
<span class="flex-60">{{ recurringDepositProduct.frequencyFixDay }}</span>
</div>
}
@if (recurringDepositProduct.lastInterestDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Last Interest Date:</span>
<span class="flex-60">{{ recurringDepositProduct.lastInterestDate | dateFormat }}</span>
</div>
}
<!-- ── Settings ── -->
<h3 class="mat-h3 flex-100">{{ 'labels.heading.Settings' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider>
@@ -258,10 +228,13 @@
<span class="flex-60">{{ recurringDepositProduct.lienMarked | yesNo }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}:</span>
<span class="flex-60">{{ recurringDepositProduct.withdrawalFeeForTransfers | yesNo }}</span>
</div>
@if (recurringDepositProduct.lienMarked && recurringDepositProduct.lienPercentage) {
<div class="flex-100 layout-row">
<span class="flex-40">Max loan against RD:</span>
<span class="flex-60">{{ recurringDepositProduct.lienPercentage }}% of balance</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Enable Lock-in Period:</span>
@@ -279,23 +252,24 @@
}
<div class="flex-100 layout-row">
<span class="flex-40">Maturity Instructions:</span>
<span class="flex-40">Execute Maturity Instruction Automatically:</span>
<span class="flex-60">{{ recurringDepositProduct.maturityInstructions | yesNo }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Credit Maturity Proceeds:</span>
<span class="flex-60">{{ recurringDepositProduct.creditMaturityProceeds | yesNo }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Penalty Grace Period (Missed Installments):</span>
<span class="flex-60">{{ recurringDepositProduct.penaltyGracePeriodMissedInstallments | yesNo }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Panel Request Applicable:</span>
<span class="flex-60">{{ recurringDepositProduct.panelRequestApplicable | yesNo }}</span>
<span class="flex-40">Missed Installment Penalty:</span>
<span class="flex-60">
@if (recurringDepositProduct.penaltyGracePeriodMissedInstallments) {
{{
recurringDepositProduct.missedInstallmentPenaltyType === 2
? recurringDepositProduct.missedInstallmentPenaltyAmount + '% of installment'
: recurringDepositProduct.missedInstallmentPenaltyAmount + ' per installment'
}}
after {{ recurringDepositProduct.missedInstallmentGraceDays ?? 0 }} grace day(s)
} @else {
No
}
</span>
</div>
<div class="flex-100 layout-row">
@@ -347,7 +321,7 @@
</ng-container>
<ng-container matColumnDef="toPeriod">
<th mat-header-cell *matHeaderCellDef>To</th>
<td mat-cell *matCellDef="let slab">{{ slab.toPeriod ?? '—' }}</td>
<td mat-cell *matCellDef="let slab">{{ slab.toPeriod ?? '—' }}</td>
</ng-container>
<ng-container matColumnDef="periodTypeLabel">
<th mat-header-cell *matHeaderCellDef>Period</th>
@@ -406,7 +380,7 @@
<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__label">Recurring Deposit Reference Account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
recurringDepositProduct.savingsReferenceAccountId,
@@ -439,7 +413,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">Recurring Deposit Control Account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
recurringDepositProduct.savingsControlAccountId,
@@ -472,7 +446,7 @@
<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__label">Interest on Recurring Deposits</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
recurringDepositProduct.interestOnSavingsAccountId,
@@ -18,10 +18,34 @@
<mat-checkbox formControlName="loanFacility">Loan Facility</mat-checkbox>
<mat-checkbox formControlName="prematureWithdrawalOption">Premature Withdrawal option</mat-checkbox>
<mat-checkbox formControlName="tenureFlexibility">Tenure Flexibility</mat-checkbox>
<mat-checkbox formControlName="lienMarked">Lien Marked</mat-checkbox>
<mat-checkbox formControlName="withdrawalFeeForTransfers">Apply Withdrawal Fee for Transfers</mat-checkbox>
<mat-checkbox
formControlName="lienMarked"
title="RDs of this product can be pledged as loan security (guarantor account). A pledged RD cannot be closed until the loan is repaid."
>Lien Marked (can be pledged for a loan)</mat-checkbox
>
</div>
@if (recurringDepositProductSettingsForm.value.lienMarked) {
<div class="rdps-lockin-fields">
<mat-form-field appearance="outline" class="rdps-field" subscriptSizing="dynamic">
<mat-label>Max loan against RD (% of balance)</mat-label>
<input type="number" matInput formControlName="lienPercentage" min="1" max="100" step="0.01" required />
<mat-hint>
e.g. 90 = loan up to 90% of the RD balance. Lien is checked when the RD is added as a loan guarantor.
</mat-hint>
@if (recurringDepositProductSettingsForm.get('lienPercentage').hasError('required')) {
<mat-error>Enter the percentage of the RD balance that can be given as loan</mat-error>
}
@if (
recurringDepositProductSettingsForm.get('lienPercentage').hasError('min') ||
recurringDepositProductSettingsForm.get('lienPercentage').hasError('max')
) {
<mat-error>Must be between 1 and 100</mat-error>
}
</mat-form-field>
</div>
}
<mat-checkbox class="rdps-preclosure-toggle" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@@ -93,34 +117,85 @@
</div>
<div class="rdps-body rdps-body--list">
<div class="rdps-list-row">
<span>Maturity Instructions</span>
<span>
Execute maturity instruction automatically
<small class="rdps-row-hint">
On maturity day the instruction chosen when the RD was opened runs by itself - Transfer to Savings (to
the chosen or linked savings account) or Reinvest. Off: the RD stays Matured, earning the post-maturity
rate, until staff close it. Never runs while the RD has a lien.
</small>
</span>
<mat-checkbox formControlName="maturityInstructions"></mat-checkbox>
</div>
<div class="rdps-list-row">
<span
>Option to credit maturity proceeds directly to the linked account or reinvest in another product (like
FD).</span
>
<mat-checkbox formControlName="creditMaturityProceeds"></mat-checkbox>
</div>
<div class="rdps-list-row">
<span>Penalty / Grace Period for Missed Installments</span>
<span>
Penalty / Grace Period for Missed Installments
<small class="rdps-row-hint">
An installment still unpaid after the grace days is charged a penalty once, collected from the RD
balance (Income From Penalties).
</small>
</span>
<mat-checkbox formControlName="penaltyGracePeriodMissedInstallments"></mat-checkbox>
</div>
@if (recurringDepositProductSettingsForm.value.penaltyGracePeriodMissedInstallments) {
<div class="rdps-lockin-fields rdps-penalty-fields">
<mat-form-field appearance="outline" class="rdps-field" subscriptSizing="dynamic">
<mat-label>Grace days</mat-label>
<input type="number" matInput formControlName="missedInstallmentGraceDays" min="0" step="1" required />
<mat-hint>Due 10th + 5 grace days = penalty from the 16th</mat-hint>
@if (recurringDepositProductSettingsForm.get('missedInstallmentGraceDays').invalid) {
<mat-error>Whole days, 0 or more</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="rdps-field" subscriptSizing="dynamic">
<mat-label>Penalty type</mat-label>
<mat-select formControlName="missedInstallmentPenaltyType" required>
<mat-option [value]="1">Flat amount per installment</mat-option>
<mat-option [value]="2">% of the installment</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="rdps-field" subscriptSizing="dynamic">
<mat-label>{{
recurringDepositProductSettingsForm.value.missedInstallmentPenaltyType === 2 ? 'Penalty (%)' : 'Penalty amount'
}}</mat-label>
<input type="number" matInput formControlName="missedInstallmentPenaltyAmount" min="0.01" step="0.01" required />
@if (recurringDepositProductSettingsForm.get('missedInstallmentPenaltyAmount').invalid) {
<mat-error>{{
recurringDepositProductSettingsForm.value.missedInstallmentPenaltyType === 2
? 'Between 0.01 and 100'
: 'Must be more than 0'
}}</mat-error>
}
</mat-form-field>
</div>
}
<div class="rdps-list-row">
<span>Panel Request Applicable</span>
<mat-checkbox formControlName="panelRequestApplicable"></mat-checkbox>
</div>
<div class="rdps-list-row">
<span>No penalty for nominee / legal heir closure</span>
<span>
No penalty for nominee / legal heir closure
<small class="rdps-row-hint">
Premature close with reason "Nominee / legal heir claim" pays the full slab rate - no pre-closure penalty.
</small>
</span>
<mat-checkbox formControlName="noPenaltyNomineeHeirClosure"></mat-checkbox>
</div>
<div class="rdps-list-row">
<span>Deceased Settlement</span>
<span>
Deceased Settlement
<small class="rdps-row-hint">
Premature close with reason "Account holder deceased": no penalty, allowed even inside lock-in, and the
proceeds may go to a nominee's savings account.
</small>
</span>
<mat-checkbox formControlName="deceasedSettlement"></mat-checkbox>
</div>
<div class="rdps-list-row">
<span>Allow Debit Txn after due date</span>
<span>
Allow Debit Txn after due date
<small class="rdps-row-hint">
For RDs on Auto Debit. On: a missed installment keeps being collected on later days. Off: it is debited
only on its due date; a missed one stays overdue.
</small>
</span>
<mat-checkbox formControlName="allowDebitTxnAfterDueDate"></mat-checkbox>
</div>
</div>
@@ -112,6 +112,17 @@ $gray-900: #202124;
padding: 0 24px;
}
.rdps-row-hint {
display: block;
margin-top: 2px;
font-size: 12px;
opacity: 0.7;
}
.rdps-penalty-fields {
padding: 12px 0 4px;
}
.rdps-list-row {
display: flex;
align-items: center;
@@ -7,7 +7,7 @@
*/
import { Component, OnInit, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule } from '@angular/forms';
import { UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -44,7 +44,9 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit {
prematureWithdrawalOption: this.recurringDepositProductsTemplate.prematureWithdrawalOption ?? false,
tenureFlexibility: this.recurringDepositProductsTemplate.tenureFlexibility ?? false,
lienMarked: this.recurringDepositProductsTemplate.lienMarked ?? false,
withdrawalFeeForTransfers: this.recurringDepositProductsTemplate.withdrawalFeeForTransfers ?? false,
lienPercentage: this.recurringDepositProductsTemplate.lienPercentage ?? '',
// Not offered for recurring deposits - no withdrawal fee is ever charged on one - so it is always off.
withdrawalFeeForTransfers: false,
enableLockinPeriod: this.recurringDepositProductsTemplate.enableLockinPeriod ?? false,
lockinPeriodFrequency: this.recurringDepositProductsTemplate.lockinPeriodFrequency ?? '',
lockinPeriodFrequencyType: this.recurringDepositProductsTemplate.lockinPeriodFrequencyType?.id ?? '',
@@ -52,10 +54,11 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit {
preClosurePenalInterest: this.recurringDepositProductsTemplate.preClosurePenalInterest ?? '',
preClosurePenalInterestOnTypeId: this.recurringDepositProductsTemplate.preClosurePenalInterestOnType?.id ?? '',
maturityInstructions: this.recurringDepositProductsTemplate.maturityInstructions ?? false,
creditMaturityProceeds: this.recurringDepositProductsTemplate.creditMaturityProceeds ?? false,
penaltyGracePeriodMissedInstallments:
this.recurringDepositProductsTemplate.penaltyGracePeriodMissedInstallments ?? false,
panelRequestApplicable: this.recurringDepositProductsTemplate.panelRequestApplicable ?? false,
missedInstallmentGraceDays: this.recurringDepositProductsTemplate.missedInstallmentGraceDays ?? 0,
missedInstallmentPenaltyType: this.recurringDepositProductsTemplate.missedInstallmentPenaltyType ?? 1,
missedInstallmentPenaltyAmount: this.recurringDepositProductsTemplate.missedInstallmentPenaltyAmount ?? '',
noPenaltyNomineeHeirClosure: this.recurringDepositProductsTemplate.noPenaltyNomineeHeirClosure ?? false,
deceasedSettlement: this.recurringDepositProductsTemplate.deceasedSettlement ?? false,
allowDebitTxnAfterDueDate: this.recurringDepositProductsTemplate.allowDebitTxnAfterDueDate ?? false,
@@ -72,6 +75,7 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit {
prematureWithdrawalOption: [false],
tenureFlexibility: [false],
lienMarked: [false],
lienPercentage: [''],
withdrawalFeeForTransfers: [false],
enableLockinPeriod: [false],
lockinPeriodFrequency: [''],
@@ -80,21 +84,71 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit {
preClosurePenalInterest: [''],
preClosurePenalInterestOnTypeId: [''],
maturityInstructions: [false],
creditMaturityProceeds: [false],
penaltyGracePeriodMissedInstallments: [false],
panelRequestApplicable: [false],
missedInstallmentGraceDays: [0],
missedInstallmentPenaltyType: [1],
missedInstallmentPenaltyAmount: [''],
noPenaltyNomineeHeirClosure: [false],
deceasedSettlement: [false],
allowDebitTxnAfterDueDate: [false],
accountClosedZeroBalanceInDayClosing: [false],
minBalanceForInterestCalculation: ['']
});
// The loan limit only means something - and is only required - while the product allows a lien.
const lienMarked = this.recurringDepositProductSettingsForm.get('lienMarked');
const lienPercentage = this.recurringDepositProductSettingsForm.get('lienPercentage');
lienMarked.valueChanges.subscribe((marked: boolean) => {
if (marked) {
lienPercentage.setValidators([
Validators.required,
Validators.min(1),
Validators.max(100)
]);
} else {
lienPercentage.clearValidators();
}
lienPercentage.updateValueAndValidity();
});
// Missed-installment penalty: its three fields are only required - and only sent - while it is switched on.
const penaltyOn = this.recurringDepositProductSettingsForm.get('penaltyGracePeriodMissedInstallments');
const graceDays = this.recurringDepositProductSettingsForm.get('missedInstallmentGraceDays');
const penaltyType = this.recurringDepositProductSettingsForm.get('missedInstallmentPenaltyType');
const penaltyAmount = this.recurringDepositProductSettingsForm.get('missedInstallmentPenaltyAmount');
const syncPenaltyValidators = () => {
if (penaltyOn.value) {
graceDays.setValidators([
Validators.required,
Validators.min(0),
Validators.pattern(/^\d+$/)
]);
penaltyType.setValidators([Validators.required]);
penaltyAmount.setValidators([
Validators.required,
Validators.min(0.01),
...(penaltyType.value === 2 ? [Validators.max(100)] : [])
]);
} else {
graceDays.clearValidators();
penaltyType.clearValidators();
penaltyAmount.clearValidators();
}
graceDays.updateValueAndValidity({ emitEvent: false });
penaltyType.updateValueAndValidity({ emitEvent: false });
penaltyAmount.updateValueAndValidity({ emitEvent: false });
};
penaltyOn.valueChanges.subscribe(syncPenaltyValidators);
penaltyType.valueChanges.subscribe(syncPenaltyValidators);
}
get recurringDepositProductSettings() {
const settings = { ...this.recurringDepositProductSettingsForm.value };
if (!settings.penaltyGracePeriodMissedInstallments) {
delete settings.missedInstallmentGraceDays;
delete settings.missedInstallmentPenaltyType;
delete settings.missedInstallmentPenaltyAmount;
}
for (const key in settings) {
if (settings[key] === '') {
if (settings[key] === '' || settings[key] === null) {
delete settings[key];
}
}
@@ -10,18 +10,62 @@
<div class="rdpt-section">
<div class="rdpt-section__header">
<span class="rdpt-section__badge"><i class="fas fa-coins"></i></span>
<span>Deposit Amount</span>
<span>Installment Amount (per period)</span>
</div>
<div class="rdpt-body">
<div class="rdpt-grid rdpt-grid--three">
<mat-form-field appearance="outline" class="rdpt-field">
<mat-form-field appearance="outline" class="rdpt-field" subscriptSizing="dynamic">
<mat-label>Minimum</mat-label>
<input type="number" matInput formControlName="minDepositAmount" min="0" step="0.01" />
<mat-hint>Smallest installment an account may commit to. Blank = no limit.</mat-hint>
@if (recurringDepositProductTermsForm.get('minDepositAmount').hasError('min')) {
<mat-error>Minimum must be zero or greater</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="rdpt-field">
<mat-form-field appearance="outline" class="rdpt-field" subscriptSizing="dynamic">
<mat-label>Maximum</mat-label>
<input type="number" matInput formControlName="maxDepositAmount" min="0" step="0.01" />
<mat-hint>Largest installment an account may commit to. Blank = no limit.</mat-hint>
@if (recurringDepositProductTermsForm.get('maxDepositAmount').hasError('min')) {
<mat-error>Maximum must be zero or greater</mat-error>
}
</mat-form-field>
</div>
@if (recurringDepositProductTermsForm.hasError('maxBelowMin')) {
<div class="rdpt-note">
<span>⚠️</span>
<span>Maximum installment cannot be less than the minimum.</span>
</div>
}
</div>
</div>
<div class="rdpt-section">
<div class="rdpt-section__header">
<span class="rdpt-section__badge"><i class="fas fa-hourglass-half"></i></span>
<span>Tenure</span>
</div>
<div class="rdpt-body">
<div class="rdpt-grid rdpt-grid--three">
<mat-form-field appearance="outline" class="rdpt-field" subscriptSizing="dynamic">
<mat-label>Minimum Tenure</mat-label>
<input type="number" matInput formControlName="minDepositTerm" min="1" step="1" required />
<mat-hint>Shortest RD this product opens. The rate chart starts here.</mat-hint>
@if (recurringDepositProductTermsForm.get('minDepositTerm').hasError('required')) {
<mat-error>Minimum Tenure <strong>is required</strong></mat-error>
}
@if (recurringDepositProductTermsForm.get('minDepositTerm').hasError('min')) {
<mat-error>Minimum Tenure must be at least 1</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="rdpt-field" subscriptSizing="dynamic">
<mat-label>Minimum Tenure In</mat-label>
<mat-select formControlName="minDepositTermTypeId" required>
@for (unit of tenureUnitOptions; track unit.id) {
<mat-option [value]="unit.id">{{ unit.value }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
@@ -35,51 +79,54 @@
<div class="rdpt-banner">
<span class="rdpt-banner__icon">ℹ️</span>
<span>
Recurring Deposit base rate:
<b>{{ recurringDepositProductTermsForm.get('nominalAnnualInterestRate').value }}%</b>
The rate an RD earns during its term always comes from the <b>CBS Base Chart</b> slab for its tenure. The
rate below applies only after maturity.
</span>
</div>
<div class="rdpt-grid rdpt-grid--three">
<mat-form-field appearance="outline" class="rdpt-field">
<mat-label>{{ 'labels.inputs.Nominal Annual Interest' | translate }}</mat-label>
<mat-form-field appearance="outline" class="rdpt-field" subscriptSizing="dynamic">
<mat-label>Post-Maturity Interest Rate (%)</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.The default interest rate set' | translate }}"
matTooltip="Simple interest a matured RD earns from its maturity date until the customer actually collects it"
formControlName="nominalAnnualInterestRate"
required
min="0"
step="0.01"
/>
<mat-hint>Paid from maturity date to closure date if collected late. 0 = nothing extra.</mat-hint>
@if (recurringDepositProductTermsForm.get('nominalAnnualInterestRate').hasError('required')) {
<mat-error>
{{ 'labels.inputs.Nominal Annual Interest' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
Post-Maturity Interest Rate {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong> (enter 0 for none)
</mat-error>
}
@if (recurringDepositProductTermsForm.get('nominalAnnualInterestRate').hasError('min')) {
<mat-error>{{ 'labels.inputs.Nominal Annual Interest' | translate }} must be zero or greater</mat-error>
<mat-error>Post-Maturity Interest Rate must be zero or greater</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="rdpt-field">
<mat-label>Effective Date</mat-label>
<mat-form-field appearance="outline" class="rdpt-field" subscriptSizing="dynamic">
<mat-label>Product Effective From</mat-label>
<input matInput [matDatepicker]="effectiveDatePicker" formControlName="effectiveDate" />
<mat-datepicker-toggle matSuffix [for]="effectiveDatePicker"></mat-datepicker-toggle>
<mat-datepicker #effectiveDatePicker></mat-datepicker>
<mat-hint>No RD can be opened under this product before this date. Blank = from today.</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="rdpt-field">
<mat-form-field appearance="outline" class="rdpt-field" subscriptSizing="dynamic">
<mat-label>{{ 'labels.inputs.Interest Calculation Type' | translate }}</mat-label>
<mat-select
formControlName="interestCalculateSelector"
matTooltip="{{ 'tooltips.The method used to calculate interest' | translate }}"
matTooltip="Compounding: interest also earns interest. Simple: interest is earned on installments only."
>
@for (opt of interestCalculationTypeOptionData; track opt.id) {
<mat-option [value]="opt.id">{{ opt.value }}</mat-option>
}
</mat-select>
<mat-hint>{{ interestCalculationTypeHint }}</mat-hint>
</mat-form-field>
</div>
@@ -117,17 +164,6 @@
<mat-error>Days in Year <strong>is required</strong></mat-error>
</mat-form-field>
@if (recurringDepositProductTermsForm.value.interestCalculationType === 2) {
<mat-form-field appearance="outline" class="rdpt-field">
<mat-label>Average Period</mat-label>
<mat-select formControlName="averagePeriod">
@for (period of periodFrequencyData; track period.id) {
<mat-option [value]="period.value">{{ period.value }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="rdpt-field">
<mat-label>{{ 'labels.inputs.Interest Posting Period' | translate }}</mat-label>
<mat-select
@@ -149,35 +185,6 @@
</div>
<div class="rdpt-footer">
<div class="rdpt-footer__grid">
<div class="rdpt-toggle-card">
<div>
<h4>Auto Interest Post</h4>
</div>
<div class="rdpt-toggle-card__choices">
@for (option of yesNoOptions; track option.value) {
<label class="inline-flex items-center cursor-pointer">
<input type="radio" class="w-4 h-4" formControlName="autoPost" [value]="option.value" />
<span class="ml-2 text-sm">{{ option.label }}</span>
</label>
}
</div>
</div>
<mat-form-field appearance="outline" class="rdpt-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="rdpt-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>
@if (recurringDepositProductTermsForm.value.interestCalculationType === 1) {
<div class="rdpt-note">
<span>⚠️</span>
@@ -7,12 +7,32 @@
*/
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 { MatTooltip } from '@angular/material/tooltip';
import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Interest Calculation Type options a recurring deposit actually honours (matched by label, as the server does). */
const RD_INTEREST_CALCULATION_OPTIONS = [
'compounding',
'simple'
];
/** SavingsPeriodFrequencyType ids an RD tenure is counted in. */
const RD_TENURE_MONTHS = 2;
const RD_TENURE_YEARS = 3;
/** Max installment below min installment would make every account impossible to open. */
function maxNotBelowMin(group: AbstractControl): ValidationErrors | null {
const min = group.get('minDepositAmount')?.value;
const max = group.get('maxDepositAmount')?.value;
if (min === '' || min === null || max === '' || max === null) {
return null;
}
return Number(max) < Number(min) ? { maxBelowMin: true } : null;
}
@Component({
selector: 'mifosx-recurring-deposit-product-terms-step',
templateUrl: './recurring-deposit-product-terms-step.component.html',
@@ -33,14 +53,13 @@ export class RecurringDepositProductTermsStepComponent implements OnInit {
interestCompoundingPeriodTypeData: any;
interestPostingPeriodTypeData: any;
readonly tenureUnitOptions = [
{ id: RD_TENURE_MONTHS, value: 'Months' },
{ id: RD_TENURE_YEARS, value: 'Years' }
];
interestCalculationTypeData: any;
interestCalculationTypeOptionData: any;
interestCalculationDaysInYearTypeData: any;
periodFrequencyData: any[] = [];
yesNoOptions: Array<{ value: boolean; label: string }> = [
{ value: true, label: 'Yes' },
{ value: false, label: 'No' }
];
constructor() {
this.createrecurringDepositProductTermsForm();
@@ -51,28 +70,31 @@ export class RecurringDepositProductTermsStepComponent implements OnInit {
this.interestPostingPeriodTypeData = this.recurringDepositProductsTemplate.interestPostingPeriodTypeOptions;
// Standard enum (1=Daily, 2=Average) → "Interest Calculate" dropdown → interestCalculationType form control
this.interestCalculationTypeData = this.recurringDepositProductsTemplate.interestCalculationTypeOptions;
// CBS code values → "Interest Calculation Type" dropdown → interestCalculateSelector form control
this.interestCalculationTypeOptionData = this.recurringDepositProductsTemplate.interestCalculationTypeOption || [];
// CBS code values → "Interest Calculation Type" dropdown → interestCalculateSelector form control. The code is
// shared with savings/current products, so values an RD ignores (e.g. "Accrual") are left out here.
this.interestCalculationTypeOptionData = (this.recurringDepositProductsTemplate.interestCalculationTypeOption || []).filter(
(opt: any) => RD_INTEREST_CALCULATION_OPTIONS.includes(this.optionLabel(opt))
);
this.interestCalculationDaysInYearTypeData =
this.recurringDepositProductsTemplate.interestCalculationDaysInYearTypeOptions;
this.periodFrequencyData = this.recurringDepositProductsTemplate.periodFrequencyOptions || [];
this.setYesNoOptions(this.recurringDepositProductsTemplate?.yesNoOptions);
const isEditing = !!this.recurringDepositProductsTemplate.id;
const defaultPeriod = this.periodFrequencyData[0]?.value || 'Monthly';
const savedEnumTypeId =
this.recurringDepositProductsTemplate.interestCalculationType?.id ||
this.interestCalculationTypeData?.[0]?.id ||
1;
const compoundingId =
this.interestCalculationTypeOptionData.find((opt: any) => this.optionLabel(opt) === 'compounding')?.id ?? null;
const savedSelectorId = isEditing
? (this.recurringDepositProductsTemplate.interestCalculateSelector ??
this.interestCalculationTypeOptionData?.[0]?.id ??
null)
: (this.interestCalculationTypeOptionData?.[0]?.id ?? null);
? (this.recurringDepositProductsTemplate.interestCalculateSelector ?? compoundingId)
: compoundingId;
this.recurringDepositProductTermsForm.patchValue({
nominalAnnualInterestRate: this.recurringDepositProductsTemplate.nominalAnnualInterestRate ?? 0,
interestCompoundingPeriodType: this.recurringDepositProductsTemplate.interestCompoundingPeriodType?.id,
// RD interest is never posted period by period - the server credits it once, at maturity or premature
// closure - so the period is not offered. The field is still required by the server and must be no shorter
// than the compounding period, which Annually always satisfies.
interestPostingPeriodType: this.recurringDepositProductsTemplate.interestPostingPeriodType?.id,
interestCalculationType: savedEnumTypeId,
interestCalculationDaysInYearType: this.recurringDepositProductsTemplate.interestCalculationDaysInYearType?.id,
@@ -80,17 +102,29 @@ export class RecurringDepositProductTermsStepComponent implements OnInit {
? this.parseDate(this.recurringDepositProductsTemplate.effectiveDate)
: '',
interestCalculateSelector: savedSelectorId,
averagePeriod: isEditing ? this.recurringDepositProductsTemplate.averagePeriod || defaultPeriod : defaultPeriod,
autoPost: isEditing ? (this.recurringDepositProductsTemplate.autoPost ?? true) : true,
frequencyFixDay: isEditing ? (this.recurringDepositProductsTemplate.frequencyFixDay ?? 30) : 30,
lastInterestDate: this.recurringDepositProductsTemplate.lastInterestDate
? this.parseDate(this.recurringDepositProductsTemplate.lastInterestDate)
: '',
minDepositAmount: this.recurringDepositProductsTemplate.minDepositAmount ?? '',
maxDepositAmount: this.recurringDepositProductsTemplate.maxDepositAmount ?? ''
maxDepositAmount: this.recurringDepositProductsTemplate.maxDepositAmount ?? '',
minDepositTerm: this.recurringDepositProductsTemplate.minDepositTerm ?? '',
minDepositTermTypeId: this.recurringDepositProductsTemplate.minDepositTermType?.id ?? RD_TENURE_MONTHS
});
}
/** What the chosen Interest Calculation Type does to an account's interest. */
get interestCalculationTypeHint(): string {
const selected = (this.interestCalculationTypeOptionData || []).find(
(opt: any) => opt.id === this.recurringDepositProductTermsForm.value.interestCalculateSelector
);
return this.optionLabel(selected) === 'simple'
? 'Interest is earned on installments only - it never earns interest itself.'
: 'Interest earns interest every compounding period; all of it is credited at maturity.';
}
private optionLabel(opt: any): string {
return String(opt?.value ?? opt?.name ?? '')
.trim()
.toLowerCase();
}
private parseDate(dateValue: any): Date | null {
if (!dateValue) return null;
if (Array.isArray(dateValue)) {
@@ -100,57 +134,56 @@ export class RecurringDepositProductTermsStepComponent implements OnInit {
return new Date(dateValue);
}
private setYesNoOptions(options: any[]) {
if (!Array.isArray(options)) return;
const mapped = options
.map((opt: any) => {
const raw = opt?.name ?? opt?.value ?? opt;
const norm = typeof raw === 'string' ? raw.trim().toUpperCase() : raw;
if (norm === 'YES') return { value: true, label: 'Yes' };
if (norm === 'NO') return { value: false, label: 'No' };
return null;
})
.filter(Boolean);
if (mapped.length) this.yesNoOptions = mapped;
}
createrecurringDepositProductTermsForm() {
this.recurringDepositProductTermsForm = this.formBuilder.group({
nominalAnnualInterestRate: [
'',
[
Validators.required,
this.recurringDepositProductTermsForm = this.formBuilder.group(
{
nominalAnnualInterestRate: [
'',
[
Validators.required,
Validators.min(0)
]
],
interestCompoundingPeriodType: [
'',
Validators.required
],
interestPostingPeriodType: [
'',
Validators.required
],
interestCalculationType: [
'',
Validators.required
],
interestCalculationDaysInYearType: [
'',
Validators.required
],
effectiveDate: [''],
interestCalculateSelector: [null],
minDepositAmount: [
'',
Validators.min(0)
],
maxDepositAmount: [
'',
Validators.min(0)
],
minDepositTerm: [
'',
[
Validators.required,
Validators.min(1)
]
],
minDepositTermTypeId: [
RD_TENURE_MONTHS,
Validators.required
]
],
interestCompoundingPeriodType: [
'',
Validators.required
],
interestPostingPeriodType: [
'',
Validators.required
],
interestCalculationType: [
'',
Validators.required
],
interestCalculationDaysInYearType: [
'',
Validators.required
],
effectiveDate: [''],
interestCalculateSelector: [1],
averagePeriod: ['Monthly'],
autoPost: [true],
frequencyFixDay: [
30,
[Validators.min(0)]
],
lastInterestDate: [''],
minDepositAmount: [''],
maxDepositAmount: ['']
});
},
{ validators: maxNotBelowMin }
);
}
get recurringDepositProductTerms() {
@@ -160,8 +193,6 @@ export class RecurringDepositProductTermsStepComponent implements OnInit {
if (formValue.effectiveDate instanceof Date) {
formValue.effectiveDate = this.dateUtils.formatDate(formValue.effectiveDate, dateFormat) || '';
}
formValue.minDepositAmount = formValue.minDepositAmount;
formValue.maxDepositAmount = formValue.maxDepositAmount;
return formValue;
}
}
@@ -190,14 +190,14 @@
</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">{{ 'labels.inputs.Nominal Annual Interest' | translate }}</span>
<span class="vsp-k">Post-Maturity Interest Rate</span>
<span class="vsp-v vsp-v--accent"
>{{ recurringDepositProduct.nominalAnnualInterestRate | formatNumber }}<span class="vsp-suffix">%</span></span
>
</div>
@if (recurringDepositProduct.effectiveDate) {
<div class="vsp-kv">
<span class="vsp-k">Effective Date</span>
<span class="vsp-k">Product Effective From</span>
<span class="vsp-v">{{ recurringDepositProduct.effectiveDate | dateFormat }}</span>
</div>
}
@@ -211,12 +211,6 @@
<span class="vsp-k">Interest Calculate</span>
<span class="vsp-v">{{ recurringDepositProduct.interestCalculationType?.value }}</span>
</div>
@if (recurringDepositProduct.averagePeriod) {
<div class="vsp-kv">
<span class="vsp-k">Average Period</span>
<span class="vsp-v">{{ recurringDepositProduct.averagePeriod }}</span>
</div>
}
<div class="vsp-kv">
<span class="vsp-k">{{ 'labels.inputs.Interest Posting Period' | translate }}</span>
<span class="vsp-v">{{ recurringDepositProduct.interestPostingPeriodType.value }}</span>
@@ -229,30 +223,6 @@
<span class="vsp-k">{{ 'labels.inputs.Days in Year' | translate }}</span>
<span class="vsp-v">{{ recurringDepositProduct.interestCalculationDaysInYearType.value }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Auto Post</span>
<span class="vsp-v">
<span
class="vsp-pill"
[class.vsp-pill--green]="recurringDepositProduct.autoPost"
[class.vsp-pill--gray]="!recurringDepositProduct.autoPost"
>
{{ recurringDepositProduct.autoPost | yesNo }}
</span>
</span>
</div>
@if (recurringDepositProduct.frequencyFixDay) {
<div class="vsp-kv">
<span class="vsp-k">Frequency (Fix Day)</span>
<span class="vsp-v">{{ recurringDepositProduct.frequencyFixDay }}</span>
</div>
}
@if (recurringDepositProduct.lastInterestDate) {
<div class="vsp-kv">
<span class="vsp-k">Last Interest Date</span>
<span class="vsp-v">{{ recurringDepositProduct.lastInterestDate | dateFormat }}</span>
</div>
}
</div>
</section>
@@ -313,18 +283,12 @@
</span>
</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">{{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}</span>
<span class="vsp-v">
<span
class="vsp-pill"
[class.vsp-pill--green]="recurringDepositProduct.withdrawalFeeForTransfers"
[class.vsp-pill--gray]="!recurringDepositProduct.withdrawalFeeForTransfers"
>
{{ recurringDepositProduct.withdrawalFeeForTransfers | yesNo }}
</span>
</span>
</div>
@if (recurringDepositProduct.lienMarked && recurringDepositProduct.lienPercentage) {
<div class="vsp-kv">
<span class="vsp-k">Max loan against RD</span>
<span class="vsp-v">{{ recurringDepositProduct.lienPercentage }}% of balance</span>
</div>
}
</div>
<h4 class="vsp-sub-h">{{ 'labels.inputs.Lock-in Period' | translate }}</h4>
@@ -355,7 +319,7 @@
<h4 class="vsp-sub-h">Maturity Instructions</h4>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Maturity Instructions</span>
<span class="vsp-k">Execute Maturity Instruction Automatically</span>
<span class="vsp-v">
<span
class="vsp-pill"
@@ -367,39 +331,18 @@
</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Credit Maturity Proceeds</span>
<span class="vsp-k">Missed Installment Penalty</span>
<span class="vsp-v">
<span
class="vsp-pill"
[class.vsp-pill--green]="recurringDepositProduct.creditMaturityProceeds"
[class.vsp-pill--gray]="!recurringDepositProduct.creditMaturityProceeds"
>
{{ recurringDepositProduct.creditMaturityProceeds | yesNo }}
</span>
</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Penalty Grace Period (Missed Installments)</span>
<span class="vsp-v">
<span
class="vsp-pill"
[class.vsp-pill--green]="recurringDepositProduct.penaltyGracePeriodMissedInstallments"
[class.vsp-pill--gray]="!recurringDepositProduct.penaltyGracePeriodMissedInstallments"
>
{{ recurringDepositProduct.penaltyGracePeriodMissedInstallments | yesNo }}
</span>
</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Panel Request Applicable</span>
<span class="vsp-v">
<span
class="vsp-pill"
[class.vsp-pill--green]="recurringDepositProduct.panelRequestApplicable"
[class.vsp-pill--gray]="!recurringDepositProduct.panelRequestApplicable"
>
{{ recurringDepositProduct.panelRequestApplicable | yesNo }}
</span>
@if (recurringDepositProduct.penaltyGracePeriodMissedInstallments) {
{{
recurringDepositProduct.missedInstallmentPenaltyType === 2
? recurringDepositProduct.missedInstallmentPenaltyAmount + '% of installment'
: recurringDepositProduct.missedInstallmentPenaltyAmount + ' per installment'
}}
after {{ recurringDepositProduct.missedInstallmentGraceDays ?? 0 }} grace day(s)
} @else {
<span class="vsp-pill vsp-pill--gray">{{ false | yesNo }}</span>
}
</span>
</div>
<div class="vsp-kv">
@@ -752,6 +695,7 @@
<mifosx-view-savings-accounting-details
[accountingRule]="recurringDepositProduct.accountingRule"
[accountingMappings]="recurringDepositProduct.accountingMappings"
productLabel="Recurring deposit"
></mifosx-view-savings-accounting-details>
</section>
@@ -715,16 +715,14 @@
<td class="f1s-table__num">{{ amount(v) }}</td>
}
</tr>
<!-- The base-date row carries a different date per column, and the whole
requirement hangs off those dates, so they are printed rather than inferred. -->
@if (line.baseDates?.length) {
<tr class="f1s-subnote">
<td>Base dates — the matching close one month earlier</td>
@for (d of line.baseDates; track $index) {
<td class="f1s-table__num">{{ d }}</td>
}
</tr>
}
<!--
The base dates used to print here as a row of their own, under IV(b). It is gone
for two reasons: the filed form has no such line, so it put a row on the return
that RBI's own format does not carry; and it described the base as "the matching
close one month earlier", which contradicted IV(b)'s own heading right above it.
Each column's base date is already on its heading, which is where a reader looks
for the period a column reports.
-->
}
}
@if (part.surplus?.length) {
@@ -0,0 +1,29 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { RdDashboardComponent } from './rd-dashboard.component';
const routes: Routes = [
Route.withShell([
{
path: 'rd-dashboard',
component: RdDashboardComponent,
data: { title: 'RD Dashboard', breadcrumb: 'RD Dashboard' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class RdDashboardRoutingModule {}
@@ -0,0 +1,256 @@
<!--
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="rdd-container">
<!-- Header -->
<div class="rdd-header">
<div class="rdd-header__left">
<div class="rdd-header__icon">
<fa-icon icon="piggy-bank"></fa-icon>
</div>
<div>
<h2 class="rdd-header__title">RD Dashboard</h2>
<p class="rdd-header__sub">
All running Recurring Deposits as of <strong>{{ businessDate || '—' }}</strong> (business date). Click any
card to see those RDs in the list below.
</p>
</div>
</div>
<button class="rdd-btn rdd-btn--slim" (click)="load()" [disabled]="loading">
<fa-icon icon="sync-alt" [class.fa-spin]="loading"></fa-icon>
<span>{{ loading ? 'Loading...' : 'Reload' }}</span>
</button>
</div>
@if (errorMessage) {
<div class="rdd-alert">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ errorMessage }}</span>
</div>
}
<!-- 1. Summary -->
<section class="rdd-section">
<h3 class="rdd-section__title">RD Book at a Glance</h3>
<div class="rdd-cards">
<button class="rdd-card rdd-card--success" [class.rdd-card--active]="stageFilter === 'REGULAR'" (click)="showStage('REGULAR')">
<span class="rdd-card__label"><fa-icon icon="chart-line"></fa-icon> Active RDs</span>
<span class="rdd-card__val">{{ summary.activeCount }}</span>
<span class="rdd-card__sub">Balance ₹{{ summary.activeBalance | number: '1.2-2' }}</span>
</button>
<button class="rdd-card rdd-card--info" [class.rdd-card--active]="stageFilter === 'MATURED'" (click)="showStage('MATURED')">
<span class="rdd-card__label"><fa-icon icon="calendar-check"></fa-icon> Matured – Close Pending</span>
<span class="rdd-card__val">{{ summary.maturedCount }}</span>
<span class="rdd-card__sub">To pay ₹{{ summary.maturedAmount | number: '1.2-2' }}</span>
</button>
<div class="rdd-card rdd-card--neutral rdd-card--static">
<span class="rdd-card__label"><fa-icon icon="check-circle"></fa-icon> Closed This Month</span>
<span class="rdd-card__val">{{ maturedClosedThisMonth + prematureClosedThisMonth }}</span>
<span class="rdd-card__sub">On maturity {{ maturedClosedThisMonth }} · Premature {{ prematureClosedThisMonth }}</span>
</div>
<div class="rdd-card rdd-card--neutral rdd-card--static">
<span class="rdd-card__label"><fa-icon icon="hand-holding-usd"></fa-icon> This Month's Installments</span>
<span class="rdd-card__val">{{ summary.collectionPercent }}%</span>
<div class="rdd-progress">
<div class="rdd-progress__bar" [style.width.%]="summary.collectionPercent"></div>
</div>
<span class="rdd-card__sub"
>Received ₹{{ summary.receivedThisMonth | number: '1.0-0' }} of ₹{{
summary.dueThisMonth | number: '1.0-0'
}}</span
>
</div>
<button class="rdd-card rdd-card--danger" [class.rdd-card--active]="extraFilter === 'overdue'" (click)="showExtra('overdue')">
<span class="rdd-card__label"><fa-icon icon="exclamation-circle"></fa-icon> Overdue RDs</span>
<span class="rdd-card__val">{{ summary.overdueCount }}</span>
<span class="rdd-card__sub">Pending ₹{{ summary.overdueAmount | number: '1.2-2' }}</span>
</button>
<button class="rdd-card rdd-card--warn" [class.rdd-card--active]="extraFilter === 'lien'" (click)="showExtra('lien')">
<span class="rdd-card__label"><fa-icon icon="lock"></fa-icon> Under Lien (Loan)</span>
<span class="rdd-card__val">{{ summary.lienCount }}</span>
<span class="rdd-card__sub">Lien ₹{{ summary.lienAmount | number: '1.2-2' }}</span>
</button>
</div>
</section>
<!-- 2. Installment health -->
<section class="rdd-section">
<h3 class="rdd-section__title">Installment Health</h3>
<div class="rdd-health">
<div class="rdd-tiles">
<button class="rdd-tile" [class.rdd-tile--active]="stageFilter === 'DUE_TODAY'" (click)="showStage('DUE_TODAY')">
<span class="rdd-tile__dot rdd-tile__dot--neutral"></span>
<span class="rdd-tile__label">Due today</span>
<span class="rdd-tile__val">{{ summary.dueTodayCount }}</span>
<span class="rdd-tile__sub">{{ summary.dueTodayPaidCount }} paid · {{ summary.dueTodayCount - summary.dueTodayPaidCount }} pending</span>
</button>
<button class="rdd-tile" [class.rdd-tile--active]="stageFilter === 'IN_GRACE'" (click)="showStage('IN_GRACE')">
<span class="rdd-tile__dot rdd-tile__dot--warn"></span>
<span class="rdd-tile__label">Late, in grace</span>
<span class="rdd-tile__val">{{ summary.inGraceCount }}</span>
<span class="rdd-tile__sub">₹{{ summary.inGraceAmount | number: '1.0-0' }} · no penalty yet</span>
</button>
<button class="rdd-tile" [class.rdd-tile--active]="stageFilter === 'PAST_GRACE'" (click)="showStage('PAST_GRACE')">
<span class="rdd-tile__dot rdd-tile__dot--danger"></span>
<span class="rdd-tile__label">Past grace</span>
<span class="rdd-tile__val">{{ summary.pastGraceCount }}</span>
<span class="rdd-tile__sub">₹{{ summary.pastGraceAmount | number: '1.0-0' }} · penalty applies</span>
</button>
<div class="rdd-tile rdd-tile--static">
<span class="rdd-tile__dot rdd-tile__dot--success"></span>
<span class="rdd-tile__label">Penalty income</span>
<span class="rdd-tile__val">₹{{ penaltyIncomeThisMonth | number: '1.0-0' }}</span>
<span class="rdd-tile__sub">This month · ₹{{ penaltyIncomeTotal | number: '1.0-0' }} in all</span>
</div>
</div>
<div class="rdd-ageing">
<div class="rdd-ageing__title">How late are overdue RDs?</div>
@for (b of ageingBuckets; track b.key) {
<button class="rdd-ageing__row" [class.rdd-ageing__row--active]="extraFilter === b.key" (click)="showExtra(b.key)">
<span class="rdd-ageing__label">{{ b.label }}</span>
<span class="rdd-ageing__track">
<span class="rdd-ageing__bar" [style.width.%]="ageingWidth(b.key)"></span>
</span>
<span class="rdd-ageing__count">{{ summary.ageing[b.key] || 0 }}</span>
</button>
}
</div>
</div>
</section>
<!-- 3. Action list -->
<section class="rdd-section">
<h3 class="rdd-section__title">RD List – What to Do Next</h3>
<div class="rdd-toolbar">
<div class="rdd-search">
<fa-icon icon="search" class="rdd-search__icon"></fa-icon>
<input class="rdd-search__input" type="text" placeholder="Account no. or client name" [(ngModel)]="search" />
</div>
<select class="rdd-select" [(ngModel)]="officeFilter">
<option [ngValue]="''">All branches</option>
@for (o of offices; track o.id) {
<option [ngValue]="o.id">{{ o.name }}</option>
}
</select>
<select class="rdd-select" [(ngModel)]="productFilter">
<option [ngValue]="''">All products</option>
@for (p of products; track p.id) {
<option [ngValue]="p.id">{{ p.name }}</option>
}
</select>
<select class="rdd-select" [(ngModel)]="stageFilter" (ngModelChange)="extraFilter = ''">
<option [ngValue]="''">All statuses</option>
@for (k of stageKeys; track k) {
<option [ngValue]="k">{{ stages[k].label }}</option>
}
</select>
@if (anyFilter) {
<button class="rdd-btn rdd-btn--slim" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Clear</button>
}
<span class="rdd-toolbar__spacer"></span>
<span class="rdd-count"
><strong>{{ visibleRows.length }}</strong> of {{ rows.length }} RDs</span
>
</div>
@if (loading) {
<div class="rdd-state"><fa-icon icon="spinner" [class.fa-spin]="true"></fa-icon> Loading RDs...</div>
} @else if (visibleRows.length === 0) {
<div class="rdd-state">
<fa-icon icon="inbox"></fa-icon>
{{ rows.length ? 'No RDs match these filters.' : 'No running Recurring Deposits.' }}
</div>
} @else {
<div class="rdd-table-card">
<table class="rdd-table">
<thead>
<tr>
<th>Account</th>
<th>Client</th>
<th>Product</th>
<th class="text-right">Installment</th>
<th>Next Due</th>
<th class="text-right">Overdue</th>
<th>Maturity</th>
<th>Status</th>
<th class="text-center">Auto Debit</th>
<th class="text-right">Lien</th>
<th class="text-center">Action</th>
</tr>
</thead>
<tbody>
@for (r of visibleRows; track r.id) {
<tr class="rdd-row" (click)="openAccount(r)">
<td>
<span class="rdd-mono">{{ r.accountNo }}</span>
<div class="rdd-sub">{{ r.officeName }}</div>
</td>
<td class="rdd-strong">{{ r.clientName }}</td>
<td class="rdd-muted">{{ r.productName }}</td>
<td class="text-right">
<span class="rdd-mono">₹{{ r.installmentAmount | number: '1.2-2' }}</span>
<div class="rdd-sub">{{ r.paidInstallments }} of {{ r.totalInstallments }} paid</div>
</td>
<td>
<span class="rdd-mono">{{ r.nextDueDate || '—' }}</span>
<div class="rdd-sub" [class.rdd-sub--danger]="r.daysLate > 0">{{ dueText(r) }}</div>
</td>
<td class="text-right">
@if (r.overdueInstallments > 0) {
<span class="rdd-mono rdd-danger">₹{{ r.overdueAmount | number: '1.2-2' }}</span>
<div class="rdd-sub">
{{ r.overdueInstallments }} installment{{ r.overdueInstallments === 1 ? '' : 's' }}
</div>
} @else {
<span class="rdd-muted">—</span>
}
</td>
<td>
<span class="rdd-mono">{{ r.maturityDate || '—' }}</span>
<div class="rdd-sub">{{ maturityText(r) }}</div>
</td>
<td>
<span [class]="stageClass(r.stage)" [title]="r.meta.hint">{{ r.meta.label }}</span>
@if (r.penaltyCount > 0) {
<div class="rdd-sub">Penalty ₹{{ r.penaltyAmount | number: '1.0-0' }} ({{ r.penaltyCount }}x)</div>
}
</td>
<td class="text-center">
@if (r.autoDebit) {
<span class="rdd-yes" [title]="'From ' + (r.linkedAccountNo || 'linked savings')">On</span>
} @else {
<span class="rdd-muted">Off</span>
}
</td>
<td class="text-right">
@if (r.lienAmount > 0) {
<span class="rdd-mono">₹{{ r.lienAmount | number: '1.0-0' }}</span>
} @else {
<span class="rdd-muted">—</span>
}
</td>
<td class="text-center rdd-actions" (click)="$event.stopPropagation()">
@if (r.stage === 'MATURED') {
<button class="rdd-btn rdd-btn--primary rdd-btn--xs" (click)="closeAccount(r)">Close</button>
}
<button class="rdd-btn rdd-btn--xs" (click)="openAccount(r)">View</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
</div>
@@ -0,0 +1,620 @@
/**
* 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/.
*/
/** RD Dashboard - same light, compact look as the FD Dashboard. */
:host {
display: block;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
color: #0f172a;
background: #f8fafc;
min-height: 100vh;
padding: 12px 16px;
box-sizing: border-box;
}
.rdd-container {
max-width: 1400px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 14px;
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.rdd-header {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: #ecfdf5;
color: #059669;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
&__title {
margin: 0;
font-size: 16px;
font-weight: 700;
}
&__sub {
margin: 2px 0 0;
font-size: 11.5px;
color: #64748b;
}
}
.rdd-alert {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
background: #fef2f2;
border: 1px solid #fca5a5;
color: #dc2626;
}
/* ── Sections ───────────────────────────────────────────────────────────── */
.rdd-section {
display: flex;
flex-direction: column;
gap: 8px;
&__title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: #334155;
}
}
/* ── 1. Summary cards ───────────────────────────────────────────────────── */
.rdd-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px;
}
.rdd-card {
display: flex;
flex-direction: column;
gap: 3px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #cbd5e1;
border-radius: 10px;
padding: 10px 14px;
text-align: left;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(.rdd-card--static) {
box-shadow: 0 4px 12px rgb(15 23 42 / 6%);
}
&--static {
cursor: default;
}
&__label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #334155;
}
&__val {
font-size: 22px;
font-weight: 800;
line-height: 1.15;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&--success {
border-left-color: #10b981;
.rdd-card__label fa-icon {
color: #10b981;
}
&.rdd-card--active {
background: #ecfdf5;
border-color: #6ee7b7;
}
}
&--info {
border-left-color: #3b82f6;
.rdd-card__label fa-icon {
color: #3b82f6;
}
&.rdd-card--active {
background: #eff6ff;
border-color: #93c5fd;
}
}
&--danger {
border-left-color: #ef4444;
.rdd-card__label fa-icon {
color: #ef4444;
}
&.rdd-card--active {
background: #fef2f2;
border-color: #fca5a5;
}
}
&--warn {
border-left-color: #f59e0b;
.rdd-card__label fa-icon {
color: #f59e0b;
}
&.rdd-card--active {
background: #fffbeb;
border-color: #fcd34d;
}
}
&--neutral {
border-left-color: #64748b;
.rdd-card__label fa-icon {
color: #64748b;
}
}
}
.rdd-progress {
height: 6px;
border-radius: 3px;
background: #e2e8f0;
overflow: hidden;
&__bar {
height: 100%;
background: #10b981;
border-radius: 3px;
}
}
/* ── 2. Installment health ──────────────────────────────────────────────── */
.rdd-health {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 10px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
.rdd-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.rdd-tile {
display: grid;
grid-template-columns: 10px 1fr;
column-gap: 8px;
row-gap: 2px;
align-items: center;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 10px 12px;
text-align: left;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(.rdd-tile--static) {
box-shadow: 0 4px 12px rgb(15 23 42 / 6%);
}
&--active {
border-color: #2563eb;
background: #f8fafc;
}
&--static {
cursor: default;
}
&__dot {
width: 10px;
height: 10px;
border-radius: 50%;
&--neutral {
background: #64748b;
}
&--warn {
background: #f59e0b;
}
&--danger {
background: #ef4444;
}
&--success {
background: #10b981;
}
}
&__label {
font-size: 12px;
font-weight: 700;
color: #334155;
}
&__val,
&__sub {
grid-column: 2;
}
&__val {
font-size: 20px;
font-weight: 800;
}
&__sub {
font-size: 10.5px;
color: #64748b;
}
}
.rdd-ageing {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
&__title {
font-size: 12px;
font-weight: 700;
color: #334155;
margin-bottom: 2px;
}
&__row {
display: grid;
grid-template-columns: 74px 1fr 28px;
align-items: center;
gap: 8px;
border: none;
background: transparent;
padding: 3px 4px;
border-radius: 6px;
font-family: inherit;
cursor: pointer;
&:hover,
&--active {
background: #f1f5f9;
}
}
&__label {
font-size: 11px;
color: #475569;
text-align: left;
}
&__track {
height: 8px;
background: #f1f5f9;
border-radius: 4px;
overflow: hidden;
}
&__bar {
display: block;
height: 100%;
background: #f97316;
border-radius: 4px;
}
&__count {
font-size: 12px;
font-weight: 700;
text-align: right;
}
}
/* ── 3. Action list ─────────────────────────────────────────────────────── */
.rdd-toolbar {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 6px 10px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
&__spacer {
flex: 1;
}
}
.rdd-search {
position: relative;
display: flex;
align-items: center;
width: 240px;
&__icon {
position: absolute;
left: 9px;
color: #94a3b8;
font-size: 12px;
}
&__input {
width: 100%;
padding: 5px 8px 5px 27px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
&:focus {
outline: none;
border-color: #2563eb;
}
}
}
.rdd-select {
padding: 5px 8px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
background: #fff;
color: #0f172a;
}
.rdd-count {
font-size: 11.5px;
color: #64748b;
}
.rdd-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: 1px solid #cbd5e1;
background: #fff;
color: #334155;
&:hover:not(:disabled) {
border-color: #2563eb;
color: #2563eb;
}
&--slim {
padding: 4px 10px;
font-size: 11.5px;
}
&--xs {
padding: 3px 9px;
font-size: 11px;
}
&--primary {
background: #2563eb;
border-color: #2563eb;
color: #fff;
&:hover:not(:disabled) {
background: #1d4ed8;
color: #fff;
}
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.rdd-state {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 24px;
text-align: center;
font-size: 12px;
color: #64748b;
}
.rdd-table-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow-x: auto;
}
.rdd-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
th {
padding: 8px 10px;
background: #f1f5f9;
color: #475569;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid #e2e8f0;
}
td {
padding: 7px 10px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
}
.rdd-row {
cursor: pointer;
&:hover {
background: #f8fafc;
}
}
.rdd-mono {
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
font-weight: 600;
}
.rdd-strong {
font-weight: 600;
}
.rdd-muted {
color: #64748b;
font-size: 11.5px;
}
.rdd-danger {
color: #dc2626;
}
.rdd-sub {
font-size: 10.5px;
color: #64748b;
&--danger {
color: #dc2626;
font-weight: 700;
}
}
.rdd-yes {
font-size: 11px;
font-weight: 700;
color: #059669;
}
.rdd-actions {
white-space: nowrap;
.rdd-btn + .rdd-btn {
margin-left: 4px;
}
}
.rdd-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
&--success {
background: #ecfdf5;
color: #047857;
}
&--info {
background: #eff6ff;
color: #2563eb;
}
&--warn {
background: #fffbeb;
color: #b45309;
}
&--danger {
background: #fef2f2;
color: #dc2626;
}
&--neutral {
background: #f1f5f9;
color: #475569;
}
}
@@ -0,0 +1,256 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
/** Custom Modules */
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Services */
import { AccountsService } from '../accounts/accounts.service';
/** Dashboard data + computation */
import {
RD_AGEING_BUCKETS,
RD_STAGES,
RdDashboardResponse,
RdRow,
RdStage,
RdSummary,
buildRdRow,
rdSummary,
sortRdRows
} from './rd-dashboard.compute';
/**
* RD Dashboard: every running Recurring Deposit, with its installment position as of the business date.
*
* Three parts, top to bottom: the summary cards (how the RD book stands), installment health (who is late and how
* badly), and the action list (each RD with what to do next). Every figure comes from one call to
* `/recurringdepositaccounts/dashboard`, which works out paid/late installments the same way the nightly penalty job
* does, so this screen and the penalties never disagree.
*/
@Component({
selector: 'mifosx-rd-dashboard',
templateUrl: './rd-dashboard.component.html',
styleUrls: ['./rd-dashboard.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class RdDashboardComponent implements OnInit {
private accountsService = inject(AccountsService);
private router = inject(Router);
loading = false;
errorMessage = '';
businessDate = '';
maturedClosedThisMonth = 0;
prematureClosedThisMonth = 0;
penaltyIncomeThisMonth = 0;
penaltyIncomeTotal = 0;
rows: RdRow[] = [];
summary: RdSummary = rdSummary([]);
/** Filters */
stageFilter: RdStage | '' = '';
/** Narrower than a stage: 'overdue' (any late installment), 'lien', or an ageing bucket key. */
extraFilter = '';
officeFilter: number | '' = '';
productFilter: number | '' = '';
search = '';
offices: { id: number; name: string }[] = [];
products: { id: number; name: string }[] = [];
readonly stages = RD_STAGES;
readonly stageKeys: RdStage[] = [
'MATURED',
'PAST_GRACE',
'IN_GRACE',
'DUE_TODAY',
'REGULAR'
];
readonly ageingBuckets = RD_AGEING_BUCKETS;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.accountsService.getRecurringDepositDashboard().subscribe({
next: (res: RdDashboardResponse) => {
this.businessDate = res?.businessDate || '';
this.maturedClosedThisMonth = Number(res?.maturedClosedThisMonth) || 0;
this.prematureClosedThisMonth = Number(res?.prematureClosedThisMonth) || 0;
this.penaltyIncomeThisMonth = Number(res?.penaltyIncomeThisMonth) || 0;
this.penaltyIncomeTotal = Number(res?.penaltyIncomeTotal) || 0;
const accounts = Array.isArray(res?.accounts) ? res.accounts : [];
this.rows = sortRdRows(accounts.map((a) => buildRdRow(a, this.businessDate)));
this.summary = rdSummary(this.rows);
this.offices = this.distinct(this.rows.map((r) => ({ id: r.officeId, name: r.officeName })));
this.products = this.distinct(this.rows.map((r) => ({ id: r.productId, name: r.productName })));
this.loading = false;
},
error: (err: any) => {
this.rows = [];
this.summary = rdSummary([]);
this.loading = false;
this.errorMessage =
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the RD dashboard.';
}
});
}
private distinct(items: { id: number; name: string }[]): { id: number; name: string }[] {
const seen = new Map<number, string>();
items.forEach((i) => seen.set(i.id, i.name));
return [...seen.entries()].map(([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name));
}
// ── Filtering ──────────────────────────────────────────────────────────────
get visibleRows(): RdRow[] {
const needle = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.stageFilter && r.stage !== this.stageFilter) {
return false;
}
if (this.extraFilter === 'overdue' && !(r.overdueInstallments > 0 && r.stage !== 'MATURED')) {
return false;
}
if (this.extraFilter === 'lien' && !(r.lienAmount > 0)) {
return false;
}
if (this.isAgeingKey(this.extraFilter) && r.ageingKey !== this.extraFilter) {
return false;
}
if (this.officeFilter !== '' && r.officeId !== this.officeFilter) {
return false;
}
if (this.productFilter !== '' && r.productId !== this.productFilter) {
return false;
}
if (needle && !`${r.accountNo} ${r.clientName}`.toLowerCase().includes(needle)) {
return false;
}
return true;
});
}
private isAgeingKey(key: string): boolean {
return this.ageingBuckets.some((b) => b.key === key);
}
/** Clicking a card or tile shows just those RDs in the list below; clicking it again shows all. */
showStage(stage: RdStage): void {
const same = this.stageFilter === stage && !this.extraFilter;
this.stageFilter = same ? '' : stage;
this.extraFilter = '';
}
showExtra(key: string): void {
const same = this.extraFilter === key && !this.stageFilter;
this.extraFilter = same ? '' : key;
this.stageFilter = '';
}
get anyFilter(): boolean {
return !!(this.stageFilter || this.extraFilter || this.officeFilter !== '' || this.productFilter !== '' || this.search);
}
resetFilters(): void {
this.stageFilter = '';
this.extraFilter = '';
this.officeFilter = '';
this.productFilter = '';
this.search = '';
}
/** Width of an ageing bar, relative to the largest bucket. */
ageingWidth(key: string): number {
const max = Math.max(1, ...this.ageingBuckets.map((b) => this.summary.ageing[b.key] || 0));
return Math.round(((this.summary.ageing[key] || 0) / max) * 100);
}
// ── Navigation ─────────────────────────────────────────────────────────────
openAccount(row: RdRow): void {
this.router.navigate([
'/clients',
row.clientId,
'recurring-deposits-accounts',
row.id,
'general'
]);
}
closeAccount(row: RdRow): void {
this.router.navigate([
'/clients',
row.clientId,
'recurring-deposits-accounts',
row.id,
'actions',
'Close'
]);
}
// ── Presentation ───────────────────────────────────────────────────────────
stageClass(stage: RdStage): string {
return `rdd-badge rdd-badge--${RD_STAGES[stage].tone}`;
}
/** "5 days late" / "Due today" / "Due in 12 days", as a branch officer would say it. */
dueText(row: RdRow): string {
if (row.stage === 'MATURED') {
return 'Term complete';
}
if (!row.nextDueDate) {
return 'All installments paid';
}
if (row.daysLate > 0) {
return `${row.daysLate} day${row.daysLate === 1 ? '' : 's'} late`;
}
const days = this.daysUntil(row.nextDueDate);
if (days === 0) {
return 'Due today';
}
return `Due in ${days} day${days === 1 ? '' : 's'}`;
}
maturityText(row: RdRow): string {
if (row.daysToMaturity == null) {
return '';
}
if (row.daysToMaturity < 0) {
return `Matured ${-row.daysToMaturity} day${row.daysToMaturity === -1 ? '' : 's'} ago`;
}
if (row.daysToMaturity === 0) {
return 'Matures today';
}
return `In ${row.daysToMaturity} day${row.daysToMaturity === 1 ? '' : 's'}`;
}
private daysUntil(iso: string): number {
const a = new Date(`${this.businessDate}T00:00:00Z`).getTime();
const b = new Date(`${iso}T00:00:00Z`).getTime();
return Math.round((b - a) / 86400000);
}
}
@@ -0,0 +1,214 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Where a running Recurring Deposit stands today, most urgent first:
* - MATURED: the term is over - the deposit is waiting to be closed and paid out.
* - PAST_GRACE: an installment is late beyond the product's grace days - the penalty applies.
* - IN_GRACE: an installment is late but still inside the grace days - no penalty yet.
* - DUE_TODAY: today's installment has not come in yet.
* - REGULAR: nothing due or late.
*/
export type RdStage = 'MATURED' | 'PAST_GRACE' | 'IN_GRACE' | 'DUE_TODAY' | 'REGULAR';
export interface RdStageMeta {
label: string;
tone: 'danger' | 'warn' | 'info' | 'success' | 'neutral';
hint: string;
}
export const RD_STAGES: Record<RdStage, RdStageMeta> = {
MATURED: { label: 'Matured – Close Pending', tone: 'info', hint: 'Term complete; close it to pay the client.' },
PAST_GRACE: { label: 'Overdue – Penalty', tone: 'danger', hint: 'Late beyond the grace days; penalty applies.' },
IN_GRACE: { label: 'Late – In Grace', tone: 'warn', hint: 'Late, but still within the grace days.' },
DUE_TODAY: { label: 'Due Today', tone: 'neutral', hint: "Today's installment has not come in yet." },
REGULAR: { label: 'Regular', tone: 'success', hint: 'All installments paid on time.' }
};
/** Late-installment ageing buckets, by days since the oldest unpaid installment fell due. */
export const RD_AGEING_BUCKETS = [
{ key: '1-30', label: '1–30 days', from: 1, to: 30 },
{ key: '31-60', label: '31–60 days', from: 31, to: 60 },
{ key: '61-90', label: '61–90 days', from: 61, to: 90 },
{ key: '90+', label: '90+ days', from: 91, to: Infinity }
];
/** SavingsAccountStatusType.MATURED. */
const STATUS_MATURED = 800;
/** One account as `/recurringdepositaccounts/dashboard` returns it. */
export interface RdDashboardAccount {
id: number;
accountNo: string;
clientId: number;
clientName: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
status: number;
balance: number;
installmentAmount: number;
maturityDate: string | null;
maturityAmount: number | null;
autoDebit: boolean;
linkedAccountNo: string | null;
graceDays: number;
penaltyEnabled: boolean;
totalInstallments: number;
paidInstallments: number;
nextDueDate: string | null;
overdueInstallments: number;
overdueAmount: number;
dueTodayCount: number;
dueTodayPaid: number;
dueThisMonth: number;
receivedThisMonth: number;
paidThisMonth: number;
penaltyCount: number;
penaltyAmount: number;
lienAmount: number;
}
export interface RdDashboardResponse {
businessDate: string;
maturedClosedThisMonth: number;
prematureClosedThisMonth: number;
penaltyIncomeThisMonth: number;
penaltyIncomeTotal: number;
accounts: RdDashboardAccount[];
}
export interface RdRow extends RdDashboardAccount {
stage: RdStage;
meta: RdStageMeta;
/** Days since the oldest unpaid installment fell due; 0 when nothing is late. */
daysLate: number;
/** Days left until maturity; negative once past it. */
daysToMaturity: number | null;
ageingKey: string | null;
}
function daysBetween(earlier: string, later: string): number {
const a = new Date(`${earlier}T00:00:00Z`).getTime();
const b = new Date(`${later}T00:00:00Z`).getTime();
return Math.round((b - a) / 86400000);
}
export function buildRdRow(a: RdDashboardAccount, today: string): RdRow {
const num = (v: any) => Number(v) || 0;
const account: RdDashboardAccount = {
...a,
balance: num(a.balance),
installmentAmount: num(a.installmentAmount),
maturityAmount: a.maturityAmount == null ? null : num(a.maturityAmount),
graceDays: num(a.graceDays),
totalInstallments: num(a.totalInstallments),
paidInstallments: num(a.paidInstallments),
overdueInstallments: num(a.overdueInstallments),
overdueAmount: num(a.overdueAmount),
dueTodayCount: num(a.dueTodayCount),
dueTodayPaid: num(a.dueTodayPaid),
dueThisMonth: num(a.dueThisMonth),
receivedThisMonth: num(a.receivedThisMonth),
paidThisMonth: num(a.paidThisMonth),
penaltyCount: num(a.penaltyCount),
penaltyAmount: num(a.penaltyAmount),
lienAmount: num(a.lienAmount)
};
// When anything is late, the earliest unpaid installment is the oldest late one.
const daysLate = account.overdueInstallments > 0 && account.nextDueDate ? daysBetween(account.nextDueDate, today) : 0;
const daysToMaturity = account.maturityDate ? daysBetween(today, account.maturityDate) : null;
let stage: RdStage;
if (account.status === STATUS_MATURED || (daysToMaturity != null && daysToMaturity <= 0)) {
stage = 'MATURED';
} else if (daysLate > 0) {
stage = daysLate > account.graceDays ? 'PAST_GRACE' : 'IN_GRACE';
} else if (account.dueTodayCount > account.dueTodayPaid) {
stage = 'DUE_TODAY';
} else {
stage = 'REGULAR';
}
const bucket = daysLate > 0 ? RD_AGEING_BUCKETS.find((b) => daysLate >= b.from && daysLate <= b.to) : null;
return {
...account,
stage,
meta: RD_STAGES[stage],
daysLate,
daysToMaturity,
ageingKey: bucket ? bucket.key : null
};
}
export interface RdSummary {
activeCount: number;
activeBalance: number;
maturedCount: number;
maturedAmount: number;
dueThisMonth: number;
receivedThisMonth: number;
collectionPercent: number;
overdueCount: number;
overdueAmount: number;
lienCount: number;
lienAmount: number;
dueTodayCount: number;
dueTodayPaidCount: number;
inGraceCount: number;
inGraceAmount: number;
pastGraceCount: number;
pastGraceAmount: number;
ageing: Record<string, number>;
}
export function rdSummary(rows: RdRow[]): RdSummary {
const sum = (list: RdRow[], f: (r: RdRow) => number) => list.reduce((s, r) => s + f(r), 0);
const running = rows.filter((r) => r.stage !== 'MATURED');
const matured = rows.filter((r) => r.stage === 'MATURED');
const overdue = running.filter((r) => r.overdueInstallments > 0);
const inGrace = rows.filter((r) => r.stage === 'IN_GRACE');
const pastGrace = rows.filter((r) => r.stage === 'PAST_GRACE');
const lien = rows.filter((r) => r.lienAmount > 0);
const dueThisMonth = sum(running, (r) => r.dueThisMonth);
const receivedThisMonth = sum(running, (r) => r.receivedThisMonth);
const ageing: Record<string, number> = {};
RD_AGEING_BUCKETS.forEach((b) => (ageing[b.key] = running.filter((r) => r.ageingKey === b.key).length));
return {
activeCount: running.length,
activeBalance: sum(running, (r) => r.balance),
maturedCount: matured.length,
maturedAmount: sum(matured, (r) => r.maturityAmount ?? r.balance),
dueThisMonth,
receivedThisMonth,
collectionPercent: dueThisMonth > 0 ? Math.round((receivedThisMonth / dueThisMonth) * 100) : 100,
overdueCount: overdue.length,
overdueAmount: sum(overdue, (r) => r.overdueAmount),
lienCount: lien.length,
lienAmount: sum(lien, (r) => r.lienAmount),
dueTodayCount: running.filter((r) => r.dueTodayCount > 0).length,
dueTodayPaidCount: running.filter((r) => r.dueTodayCount > 0 && r.dueTodayPaid >= r.dueTodayCount).length,
inGraceCount: inGrace.length,
inGraceAmount: sum(inGrace, (r) => r.overdueAmount),
pastGraceCount: pastGrace.length,
pastGraceAmount: sum(pastGrace, (r) => r.overdueAmount),
ageing
};
}
/** Most urgent first: matured, then the longest late, then due today, then the rest by next due date. */
export function sortRdRows(rows: RdRow[]): RdRow[] {
const rank: Record<RdStage, number> = { MATURED: 0, PAST_GRACE: 1, IN_GRACE: 2, DUE_TODAY: 3, REGULAR: 4 };
return [...rows].sort(
(a, b) =>
rank[a.stage] - rank[b.stage] ||
b.daysLate - a.daysLate ||
(a.nextDueDate || '9999').localeCompare(b.nextDueDate || '9999') ||
a.accountNo.localeCompare(b.accountNo)
);
}
@@ -0,0 +1,22 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { RdDashboardRoutingModule } from './rd-dashboard-routing.module';
import { RdDashboardComponent } from './rd-dashboard.component';
@NgModule({
imports: [
SharedModule,
RdDashboardRoutingModule,
RdDashboardComponent
]
})
export class RdDashboardModule {}
+9 -1
View File
@@ -226,7 +226,15 @@ export class SettingsService {
* Returns current Business date server
*/
get businessDate(): Date {
return this.dateUtils.convertToDate(localStorage.getItem('mifosXServerDate'), SettingsService.businessDateFormat);
// With business dates switched off the server works on today's date, so the screens must too - the date
// stored from when it was on would otherwise linger. Nothing stored (or unreadable) also falls back to today
// instead of an Invalid Date.
if (this.businessDateConfig === 'false') {
return new Date();
}
const stored = localStorage.getItem('mifosXServerDate');
const date = stored ? this.dateUtils.convertToDate(stored, SettingsService.businessDateFormat) : null;
return date && !isNaN(date.getTime()) ? date : new Date();
}
/**
+1 -1
View File
@@ -749,7 +749,7 @@
"Daily": "Daily",
"Monthly": "Monthly",
"Weekly": "Weekly",
"Quarterly": "Quartely",
"Quarterly": "Quarterly",
"Semi-Annual": "Semi-Annual",
"SemiWeeks": "SemiWeeks",
"Annually": "Annually",
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '261006',
'hash': '5ab4e9ea'
'version': '261012',
'hash': '6b0f218b'
},
'allow_switching_backend_instance': true
};