Merge dev into demo-backup
Brings in all of dev's latest content not present on demo-backup (Share Withdrawal/Closure feature, TDS reports, KYC exception tracking, client-edit-email-dialog, form121 tab, search-select) while preserving demo-backup's own unique changes (wrapping Approval Center tab nav, Loan Origination/Investment mega-menus). 36 files that a prior "ours wins" conflict resolution had silently dropped were restored explicitly from dev's tip.
This commit is contained in:
+90
@@ -0,0 +1,90 @@
|
||||
<div class="fm-dialog">
|
||||
<div class="fm-dialog__header" style="display: flex; align-items: center; gap: 8px;">
|
||||
<mat-icon class="fm-dialog__icon">people</mat-icon>
|
||||
<span style="font-weight: 500; font-size: 18px;">CHILDREN</span>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
<form [formGroup]="childrenForm">
|
||||
<div formArrayName="children" style="margin-top: 12px; background: #fff; padding: 12px;">
|
||||
@for (child of children.controls; track $index) {
|
||||
<div [formGroupName]="$index"
|
||||
style="position: relative; padding: 24px 16px 16px; margin-top: 16px; margin-bottom: 24px; border: 1px solid #e0e0e0; border-radius: 8px; background: #fff;">
|
||||
<div class="fm-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Child Full Name*</mat-label>
|
||||
<input matInput formControlName="fullName" required placeholder="Child Full Name*" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Gender*</mat-label>
|
||||
<mat-select formControlName="genderId" required>
|
||||
@for (g of genderIdOptions; track g.id) {
|
||||
<mat-option [value]="g.id">{{ g.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="childDobPicker.open()">
|
||||
<mat-label>Date of Birth</mat-label>
|
||||
<input matInput [max]="maxDate" [matDatepicker]="childDobPicker" formControlName="dateOfBirth" />
|
||||
<mat-datepicker-toggle matSuffix [for]="childDobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #childDobPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Nationality</mat-label>
|
||||
<mat-select formControlName="nationality">
|
||||
<mat-option value="INDIAN">Indian</mat-option>
|
||||
<mat-option value="NRI">NRI</mat-option>
|
||||
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
|
||||
<mat-option value="OTHER">Other</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Aadhaar Number (Optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="aadhaarNumber"
|
||||
maxlength="12"
|
||||
placeholder="12-digit Aadhaar"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>PAN Number (Optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="panNumber"
|
||||
maxlength="10"
|
||||
placeholder="ABCDE1234F"
|
||||
style="text-transform: uppercase"
|
||||
(input)="
|
||||
$any(children.at($index)).get('panNumber')
|
||||
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||
"
|
||||
/>
|
||||
@if (
|
||||
$any(children.at($index)).get('panNumber')?.hasError('pattern') && $any(children.at($index)).get('panNumber')?.touched
|
||||
) {
|
||||
<mat-error>Invalid PAN format</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
@if ($index < children.controls.length - 1) { <div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px;">
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px;"></div>
|
||||
<button mat-stroked-button type="button" color="primary" (click)="addChild()">
|
||||
<mat-icon>add</mat-icon> Add Another Child
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="childrenForm.invalid" (click)="submit()">
|
||||
Add Member
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { Component, Inject, OnInit } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
|
||||
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'cbs-client-family-member-child-dialog',
|
||||
templateUrl: './client-family-member-child-dialog.component.html',
|
||||
styleUrls: ['../client-family-member-dialog/client-family-member-dialog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatIcon,
|
||||
MatButtonModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatDatepickerModule,
|
||||
MatTooltipModule
|
||||
]
|
||||
})
|
||||
export class ClientFamilyMemberChildDialogComponent implements OnInit {
|
||||
|
||||
childrenForm: UntypedFormGroup;
|
||||
genderIdOptions: any[];
|
||||
maxDate: Date = new Date();
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<ClientFamilyMemberChildDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: any,
|
||||
private formBuilder: UntypedFormBuilder
|
||||
) {
|
||||
this.genderIdOptions = data.genderIdOptions;
|
||||
|
||||
this.childrenForm = this.formBuilder.group({
|
||||
children: this.formBuilder.array([])
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.data.children && this.data.children.length > 0) {
|
||||
this.data.children.forEach((child: any) => {
|
||||
this.children.push(this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [child.fullName, Validators.required],
|
||||
genderId: [child.genderId, Validators.required],
|
||||
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
this.addChild();
|
||||
}
|
||||
}
|
||||
|
||||
get children(): UntypedFormArray {
|
||||
return this.childrenForm.get('children') as UntypedFormArray;
|
||||
}
|
||||
|
||||
createChildFormGroup(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
id: [''],
|
||||
fullName: ['', Validators.required],
|
||||
genderId: ['', Validators.required],
|
||||
dateOfBirth: [''],
|
||||
nationality: ['INDIAN'],
|
||||
aadhaarNumber: [''],
|
||||
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||
});
|
||||
}
|
||||
|
||||
addChild(): void {
|
||||
this.children.push(this.createChildFormGroup());
|
||||
}
|
||||
|
||||
removeChild(index: number): void {
|
||||
this.children.removeAt(index);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.childrenForm.valid) {
|
||||
this.dialogRef.close(this.children.value);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -968,6 +968,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
|
||||
}
|
||||
|
||||
/** Guards the Monthly/Annual and Income/Turnover auto-sync subscriptions below — set this to
|
||||
* true while programmatically writing both fields of a pair to avoid a feedback loop. */
|
||||
private suppressMonthlyAnnualLink = false;
|
||||
|
||||
/**
|
||||
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
|
||||
* one auto-fills the other. Uses { emitEvent: false } on the write side so the two
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
<!--
|
||||
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="f121">
|
||||
@if (loading) {
|
||||
<div class="f121__loading">
|
||||
<mat-spinner [diameter]="36"></mat-spinner>
|
||||
<span>Reading the customer's deposit ledger…</span>
|
||||
</div>
|
||||
} @else if (loadError) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<div>Could not load the customer's accounts. Try again, or check the deposit service.</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- ── Header ── -->
|
||||
<div class="f121__head">
|
||||
<div>
|
||||
<h3 class="f121__title">Form 121 & TDS on deposit interest</h3>
|
||||
<div class="f121__sub">
|
||||
Tax deducted at source under {{ statute.section }} on fixed and recurring deposit interest.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="f121__controls">
|
||||
@if (availableFys.length) {
|
||||
<label class="f121__field">
|
||||
<span>Financial year</span>
|
||||
<select [(ngModel)]="fy" (ngModelChange)="recompute()">
|
||||
@for (year of availableFys; track year) {
|
||||
<option [value]="year">FY {{ year }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
<div class="f121__field">
|
||||
<span>Settlement</span>
|
||||
<div
|
||||
class="f121__mode"
|
||||
[matTooltip]="'Set by the deposit product\'s interest-posting frequency: tax falls due when interest is credited, so the branch cannot defer it. Not a customer preference.'"
|
||||
>
|
||||
<mat-icon>schedule</mat-icon>
|
||||
{{ mode === 'QUARTERLY' ? 'Quarterly' : 'At year end' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!schedule) {
|
||||
<div class="f121__note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<div>
|
||||
@if (!dateOfBirth) {
|
||||
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
|
||||
Data tab.
|
||||
} @else {
|
||||
No interest has been credited to this customer's deposits yet.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- ── KPI strip ── -->
|
||||
<div class="f121__kpis">
|
||||
<div class="f121__kpi">
|
||||
<div class="f121__kpi-n">₹{{ schedule.totalInterest | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">Interest earned (FY {{ schedule.fy }})</div>
|
||||
</div>
|
||||
<div class="f121__kpi" [class.f121__kpi--warn]="actualTds > 0">
|
||||
<div class="f121__kpi-n">₹{{ actualTds | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">TDS deducted</div>
|
||||
</div>
|
||||
<div class="f121__kpi">
|
||||
<div class="f121__kpi-n">₹{{ schedule.threshold | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">Threshold ({{ schedule.senior ? 'senior citizen' : 'general' }})</div>
|
||||
</div>
|
||||
@if (thresholdCrossed) {
|
||||
<div class="f121__kpi f121__kpi--warn">
|
||||
<div class="f121__kpi-n">Crossed</div>
|
||||
<div class="f121__kpi-l">TDS applies this year</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="f121__kpi f121__kpi--ok">
|
||||
<div class="f121__kpi-n">₹{{ headroom | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">More interest before TDS starts</div>
|
||||
</div>
|
||||
}
|
||||
@if (tdsSaved > 0) {
|
||||
<div class="f121__kpi f121__kpi--ok">
|
||||
<div class="f121__kpi-n">₹{{ tdsSaved | number: '1.0-0' }}</div>
|
||||
<div class="f121__kpi-l">Saved by Form 121</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── Alerts ── -->
|
||||
@if (variance !== 0) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>warning_amber</mat-icon>
|
||||
<div>
|
||||
<strong>Ledger and computed tax differ by ₹{{ variance | number: '1.0-0' }}.</strong>
|
||||
The ledger shows ₹{{ actualTds | number: '1.0-0' }} withheld; {{ statute.section }} on this year's interest
|
||||
comes to ₹{{ schedule.totalTds | number: '1.0-0' }}. Investigate before the quarterly return is filed.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (declarationNotAppliedToAccounts) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>warning_amber</mat-icon>
|
||||
<div>
|
||||
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
|
||||
deposits. Turn it off on those accounts, or tax will keep being deducted.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (declarationUselessOnJointAccounts) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>warning_amber</mat-icon>
|
||||
<div>
|
||||
This customer has filed Form 121, but tax on
|
||||
{{ jointHolderAccounts.length }} joint deposit{{ jointHolderAccounts.length === 1 ? '' : 's' }} is deducted
|
||||
against the first-named holder. Their declaration does not cover those — the primary holder must file their
|
||||
own.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (shouldSuggestDeclaration) {
|
||||
<div class="f121__note f121__note--tip">
|
||||
<mat-icon>lightbulb_outline</mat-icon>
|
||||
<div>
|
||||
₹{{ schedule.totalTds | number: '1.0-0' }} of tax falls due this year. If the customer's total tax liability
|
||||
for the year is nil, a {{ formLabel }} declaration would stop it being deducted at all — better than
|
||||
reclaiming it later as a refund.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!panWellFormed) {
|
||||
<div class="f121__note f121__note--warn">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<div>
|
||||
No valid PAN on record, so tax is deducted at the higher section 206AA rate of
|
||||
{{ schedule.rateApplied * 100 | number: '1.0-0' }}%. A Form 121 declaration is void without a valid PAN.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (note of schedule.notes; track note) {
|
||||
<div class="f121__note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── Declaration panel ── -->
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>assignment_turned_in</mat-icon>
|
||||
<span>Declaration</span>
|
||||
</div>
|
||||
<div class="f121__kv">
|
||||
<div class="f121__kv-row">
|
||||
<span>Status</span>
|
||||
<span>
|
||||
@if (declarationOnRecord) {
|
||||
<span class="f121__pill f121__pill--ok">On record — {{ formLabel }}</span>
|
||||
} @else {
|
||||
<span class="f121__pill">Not filed</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="f121__kv-row">
|
||||
<span>Form & threshold band</span>
|
||||
<span>{{ formLabel }} · {{ bandLabel }} band · {{ age }} yrs in this FY</span>
|
||||
</div>
|
||||
<div class="f121__kv-row">
|
||||
<span>PAN</span>
|
||||
<span class="f121__mono">
|
||||
{{ pan || '—' }}
|
||||
@if (pan && !panWellFormed) {
|
||||
<span class="f121__pill f121__pill--warn">invalid</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (declarationOnRecord && declarationFy) {
|
||||
<div class="f121__kv-row">
|
||||
<span>Declared for</span>
|
||||
<span>FY {{ declarationFy }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (declarationOnRecord && !acceptanceDateKnown) {
|
||||
<div class="f121__kv-row f121__kv-row--muted">
|
||||
<span>Acceptance date</span>
|
||||
<span>
|
||||
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
|
||||
the figures below.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (eligibility) {
|
||||
@for (reason of eligibility.reasons; track reason) {
|
||||
<div class="f121__kv-row f121__kv-row--warn">
|
||||
<span>Eligibility</span>
|
||||
<span>{{ reason }}</span>
|
||||
</div>
|
||||
}
|
||||
@for (note of eligibility.unassessed; track note) {
|
||||
<div class="f121__kv-row f121__kv-row--muted">
|
||||
<span>Not assessed</span>
|
||||
<span>{{ note }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Quarter-wise ── -->
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>table_chart</mat-icon>
|
||||
<span>Quarter by quarter</span>
|
||||
</div>
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quarter</th>
|
||||
<th class="num">Interest</th>
|
||||
<th class="num">Cumulative</th>
|
||||
<th class="num">Liability to date</th>
|
||||
<th class="num">TDS this quarter</th>
|
||||
<th>Return due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of schedule.lines; track trackLine($index, line)) {
|
||||
<tr [class.f121__row--warn]="line.thresholdCrossedHere">
|
||||
<td>
|
||||
{{ line.label }}
|
||||
@if (line.thresholdCrossedHere) {
|
||||
<span class="f121__pill f121__pill--warn">threshold crossed</span>
|
||||
}
|
||||
@if (line.declarationActive) {
|
||||
<span class="f121__pill f121__pill--ok">Form 121 active</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
@if (line.tds !== 0) {
|
||||
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="muted">{{ line.dueDate }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="f121__row--total">
|
||||
<td><strong>Total</strong></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ schedule.totalInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (schedule.shieldedInterest > 0) {
|
||||
<div class="f121__foot">
|
||||
₹{{ schedule.shieldedInterest | number: '1.0-0' }} was paid without deduction on the strength of the
|
||||
declaration. This amount is still reportable in the quarterly {{ statute.statement }} return.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── Section 1: accounts that count ── -->
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>account_balance</mat-icon>
|
||||
<span>Deposits counted towards this customer's TDS</span>
|
||||
</div>
|
||||
@if (liableAccounts.length) {
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Interest (FY)</th>
|
||||
<th class="num">TDS deducted</th>
|
||||
<th>Withholding</th>
|
||||
<th>Settles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of liableAccounts; track trackAccount($index, a)) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||
<div class="muted">{{ a.productName }}</div>
|
||||
</td>
|
||||
<td>{{ a.kind }}</td>
|
||||
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ a.tdsDeducted | number: '1.0-0' }}</td>
|
||||
<td>
|
||||
@if (a.withHoldTax) {
|
||||
<span class="f121__pill f121__pill--warn">On</span>
|
||||
@if (a.taxGroup) {
|
||||
<div class="muted">{{ a.taxGroup }}</div>
|
||||
}
|
||||
} @else {
|
||||
<span class="f121__pill">Off</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ a.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="f121__foot">
|
||||
Aggregated across every branch — a bank on core banking computes the deduction threshold for the bank as a
|
||||
whole, not branch by branch.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="f121__empty">No fixed or recurring deposits where this customer is the deductee.</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── Section 2: joint holdings ── -->
|
||||
@if (jointHolderAccounts.length) {
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>group</mat-icon>
|
||||
<span>Deposits where this customer is a joint holder</span>
|
||||
</div>
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Interest (FY)</th>
|
||||
<th>Deducted against</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of jointHolderAccounts; track trackAccount($index, a)) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||
<div class="muted">{{ a.productName }}</div>
|
||||
</td>
|
||||
<td>{{ a.kind }}</td>
|
||||
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||
<td class="muted">
|
||||
First-named holder
|
||||
@if (a.deducteeClientId) {
|
||||
(client {{ a.deducteeClientId }})
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="f121__foot">
|
||||
Tax on a joint deposit is deducted against the first-named holder's PAN, so this interest is
|
||||
<strong>not</strong> counted in the figures above and does not use up this customer's threshold.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── Section 3: exempt accounts ── -->
|
||||
@if (exemptAccounts.length) {
|
||||
<div class="f121__card">
|
||||
<div class="f121__card-head">
|
||||
<mat-icon>savings</mat-icon>
|
||||
<span>Savings and current accounts</span>
|
||||
</div>
|
||||
<div class="f121__table-wrap">
|
||||
<table class="f121__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Interest (FY)</th>
|
||||
<th>TDS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of exemptAccounts; track trackAccount($index, a)) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="f121__mono">{{ a.accountNo }}</div>
|
||||
<div class="muted">{{ a.productName }}</div>
|
||||
</td>
|
||||
<td>{{ a.kind }}</td>
|
||||
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
|
||||
<td><span class="f121__pill">Not applicable</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="f121__foot">
|
||||
The charge applies to time deposits. Savings bank interest is outside it and is excluded from the threshold
|
||||
— the customer claims the 80TTA or 80TTB deduction on it in their own return instead.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.f121 {
|
||||
--f121-line: #e5e7eb;
|
||||
--f121-bg: #f9fafb;
|
||||
--f121-ink: #111827;
|
||||
--f121-muted: #6b7280;
|
||||
--f121-blue: #1d4ed8;
|
||||
--f121-green: #15803d;
|
||||
--f121-amber: #b45309;
|
||||
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--f121-ink);
|
||||
}
|
||||
|
||||
.f121__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 40px 16px;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.f121__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.f121__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.f121__sub {
|
||||
margin-top: 4px;
|
||||
color: var(--f121-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.f121__controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.f121__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
> span {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.f121__mode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-radius: 6px;
|
||||
background: var(--f121-bg);
|
||||
font-weight: 600;
|
||||
cursor: help;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── KPI strip ── */
|
||||
.f121__kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.f121__kpi {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-left: 3px solid var(--f121-blue);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
|
||||
&--ok {
|
||||
border-left-color: var(--f121-green);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-left-color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
.f121__kpi-n {
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.f121__kpi-l {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--f121-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* ── Notes ── */
|
||||
.f121__note {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--f121-blue);
|
||||
background: color-mix(in srgb, var(--f121-blue) 6%, transparent);
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--f121-blue);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-left-color: var(--f121-amber);
|
||||
background: color-mix(in srgb, var(--f121-amber) 8%, transparent);
|
||||
|
||||
mat-icon {
|
||||
color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
&--tip {
|
||||
border-left-color: var(--f121-green);
|
||||
background: color-mix(in srgb, var(--f121-green) 7%, transparent);
|
||||
|
||||
mat-icon {
|
||||
color: var(--f121-green);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cards ── */
|
||||
.f121__card {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--f121-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.f121__card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 11px 14px;
|
||||
background: var(--f121-bg);
|
||||
border-bottom: 1px solid var(--f121-line);
|
||||
font-weight: 700;
|
||||
font-size: 12.5px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--f121-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.f121__foot {
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--f121-line);
|
||||
background: var(--f121-bg);
|
||||
color: var(--f121-muted);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.f121__empty {
|
||||
padding: 20px 14px;
|
||||
color: var(--f121-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Key/value list ── */
|
||||
.f121__kv-row {
|
||||
display: grid;
|
||||
grid-template-columns: 190px 1fr;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
line-height: 1.5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
> span:first-child {
|
||||
color: var(--f121-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--muted > span:last-child {
|
||||
color: var(--f121-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&--warn > span:last-child {
|
||||
color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tables ── */
|
||||
.f121__table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.f121__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--f121-line);
|
||||
color: var(--f121-muted);
|
||||
font-weight: 700;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--f121-muted);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.f121__row--warn > td {
|
||||
background: color-mix(in srgb, var(--f121-amber) 7%, transparent);
|
||||
}
|
||||
|
||||
.f121__row--total > td {
|
||||
background: var(--f121-bg);
|
||||
border-top: 1px solid var(--f121-line);
|
||||
}
|
||||
|
||||
/* ── Bits ── */
|
||||
.f121__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--f121-line);
|
||||
background: var(--f121-bg);
|
||||
color: var(--f121-muted);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
& + & {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
border-color: color-mix(in srgb, var(--f121-green) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--f121-green) 10%, transparent);
|
||||
color: var(--f121-green);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-color: color-mix(in srgb, var(--f121-amber) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--f121-amber) 12%, transparent);
|
||||
color: var(--f121-amber);
|
||||
}
|
||||
}
|
||||
|
||||
.f121__mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* 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, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Observable, Subject, forkJoin, of } from 'rxjs';
|
||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { SavingsService } from 'app/savings/savings.service';
|
||||
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
|
||||
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Domain logic */
|
||||
import {
|
||||
Declaration,
|
||||
DeclarationForm,
|
||||
EligibilityResult,
|
||||
FY_TAX_CONFIG,
|
||||
InterestCredit,
|
||||
TdsAccountInput,
|
||||
TdsMode,
|
||||
TdsQuarterLine,
|
||||
TdsSchedule,
|
||||
ageInFy,
|
||||
buildSchedule,
|
||||
checkEligibility,
|
||||
collectTdsCredits,
|
||||
declarationFormFor,
|
||||
formKindFor,
|
||||
fyOf,
|
||||
isTdsApplicable,
|
||||
isWellFormedPan,
|
||||
normaliseFy,
|
||||
tdsDeducteeOf,
|
||||
tdsModeFor
|
||||
} from 'app/letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||
|
||||
/** One of the customer's interest-bearing accounts, with its FY figures. */
|
||||
export interface Form121AccountRow {
|
||||
accountNo: string;
|
||||
productName: string;
|
||||
/** 'Fixed Deposit', 'Recurring Deposit' or 'Savings'. */
|
||||
kind: string;
|
||||
/** Interest credited to this account inside the selected FY. */
|
||||
interest: number;
|
||||
/** Tax Fineract has actually withheld on this account inside the FY. */
|
||||
tdsDeducted: number;
|
||||
/** Whether Fineract is currently withholding tax on this account. */
|
||||
withHoldTax: boolean;
|
||||
taxGroup: string | null;
|
||||
/** When tax on this account settles — from the product, not a preference. */
|
||||
mode: TdsMode;
|
||||
/** Interest here attracts TDS at all (a time deposit, not a savings account). */
|
||||
tdsApplicable: boolean;
|
||||
/** This customer is the deductee — false when they are a joint holder only. */
|
||||
isDeductee: boolean;
|
||||
/** Who the tax is deducted against, when it is not this customer. */
|
||||
deducteeClientId: number | null;
|
||||
}
|
||||
|
||||
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
|
||||
function toIso(value: unknown): string | null {
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d
|
||||
] = value as number[];
|
||||
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = new Date(value);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-form121-tab',
|
||||
templateUrl: './form121-tab.component.html',
|
||||
styleUrls: ['./form121-tab.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatProgressSpinner,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class Form121TabComponent implements OnInit, OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
private clientsService = inject(ClientsService);
|
||||
private savingsService = inject(SavingsService);
|
||||
private fixedDepositsService = inject(FixedDepositsService);
|
||||
private recurringDepositsService = inject(RecurringDepositsService);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
clientId = '';
|
||||
loading = true;
|
||||
loadError = false;
|
||||
|
||||
// ── Customer ──
|
||||
clientName = '';
|
||||
dateOfBirth = '';
|
||||
pan = '';
|
||||
panWellFormed = false;
|
||||
|
||||
// ── Declaration on record ──
|
||||
declarationOnRecord = false;
|
||||
/**
|
||||
* The form actually in force for the selected year. From FY 2026-27 a single
|
||||
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
|
||||
* than from the payee's age.
|
||||
*/
|
||||
declarationForm: DeclarationForm = '121';
|
||||
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
|
||||
declarationKind: '15G' | '15H' = '15G';
|
||||
declarationFy = '';
|
||||
/**
|
||||
* The KYC step records only the year, not the date the branch accepted the
|
||||
* declaration, so it has to be treated as covering the whole year. Surfaced on
|
||||
* screen rather than hidden — a mid-year acceptance would change the figures.
|
||||
*/
|
||||
acceptanceDateKnown = false;
|
||||
|
||||
eligibility: EligibilityResult | null = null;
|
||||
|
||||
// ── Selection ──
|
||||
availableFys: string[] = [];
|
||||
fy = '';
|
||||
|
||||
// ── Computed ──
|
||||
accounts: Form121AccountRow[] = [];
|
||||
schedule: TdsSchedule | null = null;
|
||||
actualTds = 0;
|
||||
tdsSaved = 0;
|
||||
|
||||
/** Raw account inputs, kept so the year selector can recompute cheaply. */
|
||||
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
|
||||
/** Withheld-tax postings by FY, from the ledger. */
|
||||
private actualTdsByFy = new Map<string, number>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
|
||||
this.clientId = params['clientId'];
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
// ────────────────────────────── Loading ──────────────────────────────
|
||||
|
||||
private load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = false;
|
||||
|
||||
forkJoin({
|
||||
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
|
||||
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
|
||||
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
|
||||
})
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
switchMap(({ client, kyc, accounts }) => {
|
||||
this.applyClient(client);
|
||||
this.applyKyc(kyc);
|
||||
const deposits = this.depositAccountsOf(accounts);
|
||||
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
|
||||
}),
|
||||
catchError(() => {
|
||||
this.loadError = true;
|
||||
return of([] as any[]);
|
||||
})
|
||||
)
|
||||
.subscribe((details: any[]) => {
|
||||
this.loading = false;
|
||||
this.ingest(details.filter(Boolean));
|
||||
this.recompute();
|
||||
});
|
||||
}
|
||||
|
||||
private applyClient(client: any): void {
|
||||
this.clientName = client?.displayName ?? '';
|
||||
this.dateOfBirth = toIso(client?.dateOfBirth) ?? '';
|
||||
}
|
||||
|
||||
private applyKyc(kyc: any): void {
|
||||
this.pan = (kyc?.panNumber ?? '').toUpperCase();
|
||||
this.panWellFormed = isWellFormedPan(this.pan);
|
||||
this.declarationOnRecord = !!kyc?.form15Applicable;
|
||||
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
|
||||
this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
|
||||
this.declarationFy = kyc?.form15FY ?? '';
|
||||
this.acceptanceDateKnown = false;
|
||||
}
|
||||
|
||||
/** Savings, FD and RD accounts — everything that can credit interest. */
|
||||
private depositAccountsOf(accounts: any): any[] {
|
||||
return (accounts?.savingsAccounts ?? []).filter(
|
||||
(a: any) => a.status?.active || a.status?.matured || a.status?.closed
|
||||
);
|
||||
}
|
||||
|
||||
private fetchAccount(a: any): Observable<any> {
|
||||
const id = `${a.id}`;
|
||||
const code = a.depositType?.code ?? '';
|
||||
let call: Observable<any>;
|
||||
if (code.includes('fixedDeposit')) {
|
||||
call = this.fixedDepositsService.getFixedDepositsAccountData(id);
|
||||
} else if (code.includes('recurringDeposit')) {
|
||||
call = this.recurringDepositsService.getRecurringDepositsAccountData(id);
|
||||
} else {
|
||||
call = this.savingsService.getSavingsAccountData(id);
|
||||
}
|
||||
// One unreadable account must not blank the whole tab.
|
||||
return call.pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns each account payload into an engine input.
|
||||
*
|
||||
* Joint and minor attributes are read defensively: they are captured on the
|
||||
* account stepper but may not surface on every read endpoint, and an absent
|
||||
* field must default to a single-name account rather than throw.
|
||||
*/
|
||||
private ingest(details: any[]): void {
|
||||
this.accountInputs = [];
|
||||
this.actualTdsByFy = new Map<string, number>();
|
||||
const fys = new Set<string>();
|
||||
const clientIdNum = Number(this.clientId);
|
||||
|
||||
for (const d of details) {
|
||||
const code: string = d.depositType?.code ?? '';
|
||||
const extra = d.extraAttributes ?? d.externalAttributes ?? d;
|
||||
|
||||
const ref = {
|
||||
id: d.id,
|
||||
accountNo: d.accountNo ?? `${d.id}`,
|
||||
depositTypeCode: code,
|
||||
accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE',
|
||||
clientId: d.clientId ?? clientIdNum,
|
||||
primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null,
|
||||
guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null,
|
||||
jointHolders: extra.jointHolders ?? d.jointHolders ?? [],
|
||||
interestPostingPeriodType: d.interestPostingPeriodType ?? null
|
||||
};
|
||||
|
||||
const credits: InterestCredit[] = [];
|
||||
let tdsDeducted = 0;
|
||||
|
||||
for (const t of d.transactions ?? []) {
|
||||
if (t.reversed || t.isReversal) {
|
||||
continue;
|
||||
}
|
||||
const iso = toIso(t.date);
|
||||
if (!iso) {
|
||||
continue;
|
||||
}
|
||||
fys.add(fyOf(iso));
|
||||
|
||||
if (t.transactionType?.interestPosting) {
|
||||
credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo });
|
||||
} else if (t.transactionType?.withholdTax) {
|
||||
const amount = t.amount ?? 0;
|
||||
tdsDeducted += amount;
|
||||
const txnFy = fyOf(iso);
|
||||
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
|
||||
}
|
||||
}
|
||||
|
||||
const deductee = tdsDeducteeOf(ref);
|
||||
const meta: Form121AccountRow = {
|
||||
accountNo: ref.accountNo,
|
||||
productName: d.savingsProductName ?? d.depositProductName ?? d.productName ?? '—',
|
||||
kind: code.includes('fixedDeposit')
|
||||
? 'Fixed Deposit'
|
||||
: code.includes('recurringDeposit')
|
||||
? 'Recurring Deposit'
|
||||
: 'Savings',
|
||||
interest: 0,
|
||||
tdsDeducted,
|
||||
withHoldTax: !!d.withHoldTax,
|
||||
taxGroup: d.taxGroup?.name ?? null,
|
||||
mode: tdsModeFor(ref),
|
||||
tdsApplicable: isTdsApplicable(ref),
|
||||
isDeductee: deductee === clientIdNum,
|
||||
deducteeClientId: deductee
|
||||
};
|
||||
|
||||
this.accountInputs.push({ ...ref, credits, meta });
|
||||
}
|
||||
|
||||
this.availableFys = [...fys].sort().reverse();
|
||||
if (!this.availableFys.includes(this.fy)) {
|
||||
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── Computation ─────────────────────────────
|
||||
|
||||
/** Re-runs the engine for the selected year. */
|
||||
recompute(): void {
|
||||
this.schedule = null;
|
||||
this.eligibility = null;
|
||||
this.tdsSaved = 0;
|
||||
this.accounts = this.accountInputs.map((a) => a.meta);
|
||||
|
||||
if (!this.fy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const year = normaliseFy(this.fy);
|
||||
|
||||
// Per-account interest for the selected year, for the three lists.
|
||||
for (const a of this.accountInputs) {
|
||||
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
|
||||
}
|
||||
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
|
||||
|
||||
if (!this.dateOfBirth) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The one place rules 8 to 12 are applied: savings interest dropped, joint
|
||||
// deposits attributed to the primary holder, every branch aggregated.
|
||||
const credits = collectTdsCredits(Number(this.clientId), this.accountInputs, year);
|
||||
const ctx = { fy: year, dateOfBirth: this.dateOfBirth, pan: this.pan, resident: true };
|
||||
const declaration = this.declarationForSelectedFy();
|
||||
|
||||
this.schedule = buildSchedule(this.overallMode, credits, { ...ctx, declaration });
|
||||
|
||||
if (declaration) {
|
||||
const without = buildSchedule(this.overallMode, credits, { ...ctx, declaration: null });
|
||||
this.tdsSaved = Math.max(0, without.totalTds - this.schedule.totalTds);
|
||||
}
|
||||
|
||||
// The branch cannot compute income earned outside the bank, so the income
|
||||
// limb is reported as unassessed rather than silently passed.
|
||||
this.eligibility = checkEligibility(ctx, {
|
||||
estimatedInterest: this.schedule.totalInterest,
|
||||
estimatedIncome: 0,
|
||||
incomeOnRecord: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When tax settles for this customer overall.
|
||||
*
|
||||
* Timing is per account, but the customer needs one view. A quarterly account
|
||||
* anywhere in the book means a deduction falls due at the first quarter end, so
|
||||
* that is the honest headline; the per-account list carries the detail.
|
||||
*/
|
||||
private get overallMode(): TdsMode {
|
||||
const applicable = this.accountInputs.filter((a) => a.meta.tdsApplicable && a.meta.isDeductee);
|
||||
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
|
||||
}
|
||||
|
||||
/** The declaration, but only when it was filed for the year being shown. */
|
||||
private declarationForSelectedFy(): Declaration | null {
|
||||
if (!this.declarationOnRecord || !this.fy) {
|
||||
return null;
|
||||
}
|
||||
const year = normaliseFy(this.fy);
|
||||
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: this.declarationKind,
|
||||
// No acceptance date on record — treated as covering the whole year.
|
||||
acceptedOn: `${year.slice(0, 4)}-04-01`,
|
||||
fy: year
|
||||
};
|
||||
}
|
||||
|
||||
// ───────────────────────── Template helpers ─────────────────────────
|
||||
|
||||
get mode(): TdsMode {
|
||||
return this.schedule?.mode ?? 'QUARTERLY';
|
||||
}
|
||||
|
||||
get age(): number {
|
||||
return this.dateOfBirth && this.fy ? ageInFy(this.dateOfBirth, this.fy) : 0;
|
||||
}
|
||||
|
||||
get formKind(): string {
|
||||
return this.dateOfBirth && this.fy ? formKindFor(this.dateOfBirth, this.fy) : '—';
|
||||
}
|
||||
|
||||
/** The form the payee would file for the selected year. */
|
||||
get formLabel(): string {
|
||||
if (!this.fy) {
|
||||
return '—';
|
||||
}
|
||||
const form = declarationFormFor(this.fy, this.dateOfBirth);
|
||||
return form === '121' ? 'Form 121' : `Form ${form}`;
|
||||
}
|
||||
|
||||
/** Which deduction trigger applies, now that the forms no longer split by age. */
|
||||
get bandLabel(): string {
|
||||
return this.schedule?.senior ? 'Senior citizen' : 'General';
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision and return references for the selected year. Both changed on
|
||||
* 01 April 2026, so the template reads them rather than hard-coding them.
|
||||
*/
|
||||
get statute(): { section: string; statement: string; rule: string; frequency: string } {
|
||||
const cfg = this.fy ? FY_TAX_CONFIG[normaliseFy(this.fy)] : undefined;
|
||||
return {
|
||||
section: cfg?.interestTdsSection ?? '—',
|
||||
statement: cfg?.tdsStatementForm ?? '—',
|
||||
rule: cfg?.declarationRule ?? '—',
|
||||
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||
};
|
||||
}
|
||||
|
||||
/** Accounts whose interest counts towards this customer's deduction aggregate. */
|
||||
get liableAccounts(): Form121AccountRow[] {
|
||||
return this.accounts.filter((a) => a.tdsApplicable && a.isDeductee);
|
||||
}
|
||||
|
||||
/** Deposits where this customer is a joint holder but not the deductee. */
|
||||
get jointHolderAccounts(): Form121AccountRow[] {
|
||||
return this.accounts.filter((a) => a.tdsApplicable && !a.isDeductee);
|
||||
}
|
||||
|
||||
/** Savings and current accounts — interest outside the charge on time deposits. */
|
||||
get exemptAccounts(): Form121AccountRow[] {
|
||||
return this.accounts.filter((a) => !a.tdsApplicable);
|
||||
}
|
||||
|
||||
/** Engine figure less what the ledger shows was actually withheld. */
|
||||
get variance(): number {
|
||||
return (this.schedule?.totalTds ?? 0) - this.actualTds;
|
||||
}
|
||||
|
||||
/** How much more interest before TDS starts. */
|
||||
get headroom(): number {
|
||||
if (!this.schedule) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, this.schedule.threshold - this.schedule.totalInterest);
|
||||
}
|
||||
|
||||
get thresholdCrossed(): boolean {
|
||||
return !!this.schedule && this.schedule.totalInterest > this.schedule.threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* A declaration is on record but deposit accounts are still set to withhold —
|
||||
* the branch has not switched them off.
|
||||
*/
|
||||
get declarationNotAppliedToAccounts(): boolean {
|
||||
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer filed a declaration but holds joint deposits where someone else
|
||||
* is the deductee — their form does nothing for those.
|
||||
*/
|
||||
get declarationUselessOnJointAccounts(): boolean {
|
||||
return !!this.declarationForSelectedFy() && this.jointHolderAccounts.length > 0;
|
||||
}
|
||||
|
||||
/** Worth prompting the branch to collect a declaration. */
|
||||
get shouldSuggestDeclaration(): boolean {
|
||||
return !this.declarationForSelectedFy() && (this.schedule?.totalTds ?? 0) > 0;
|
||||
}
|
||||
|
||||
trackLine(_: number, line: TdsQuarterLine): number {
|
||||
return line.quarter;
|
||||
}
|
||||
|
||||
trackAccount(_: number, account: Form121AccountRow): string {
|
||||
return account.accountNo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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 {
|
||||
deriveKycExceptionFields,
|
||||
KycExceptionInput,
|
||||
KYC_EXCEPTION_AADHAAR,
|
||||
KYC_EXCEPTION_PAN
|
||||
} from './kyc-exception.util';
|
||||
|
||||
const TODAY = '05 August 2026';
|
||||
|
||||
/** Individual client, nothing previously recorded — the client-creation case. */
|
||||
const newIndividual = (panVerified: boolean, aadhaarVerified: boolean): KycExceptionInput => ({
|
||||
panVerified,
|
||||
aadhaarVerified,
|
||||
aadhaarApplicable: true,
|
||||
hadException: false,
|
||||
previousReason: null,
|
||||
todayFormatted: TODAY
|
||||
});
|
||||
|
||||
/** Individual client that already carries an open exception. */
|
||||
const existingException = (
|
||||
panVerified: boolean,
|
||||
aadhaarVerified: boolean,
|
||||
previousReason: string
|
||||
): KycExceptionInput => ({
|
||||
panVerified,
|
||||
aadhaarVerified,
|
||||
aadhaarApplicable: true,
|
||||
hadException: true,
|
||||
previousReason,
|
||||
todayFormatted: TODAY
|
||||
});
|
||||
|
||||
describe('deriveKycExceptionFields', () => {
|
||||
describe('at client creation', () => {
|
||||
it('marks both PAN and Aadhaar when neither is entered or verified', () => {
|
||||
expect(deriveKycExceptionFields(newIndividual(false, false))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only PAN when Aadhaar verified but PAN did not', () => {
|
||||
expect(deriveKycExceptionFields(newIndividual(false, true))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: KYC_EXCEPTION_PAN,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only Aadhaar when PAN verified but Aadhaar did not', () => {
|
||||
expect(deriveKycExceptionFields(newIndividual(true, false))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: KYC_EXCEPTION_AADHAAR,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('raises nothing when both verify, and does not mark the client resolved', () => {
|
||||
// A clean client never had an exception, so it must not appear in the Resolved tab either.
|
||||
expect(deriveKycExceptionFields(newIndividual(true, true))).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: null,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('does not raise an Aadhaar exception for a non-individual client', () => {
|
||||
// Companies submit a dummy masked Aadhaar; checking it would mean a permanent exception.
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
panVerified: true,
|
||||
aadhaarVerified: false,
|
||||
aadhaarApplicable: false,
|
||||
hadException: false,
|
||||
previousReason: null,
|
||||
todayFormatted: TODAY
|
||||
}).isException
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolving', () => {
|
||||
it('closes the exception once both checks pass, keeping the reason for the audit trail', () => {
|
||||
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||
expect(deriveKycExceptionFields(existingException(true, true, both))).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: both,
|
||||
exceptionResolved: true,
|
||||
exceptionResolvedOn: TODAY
|
||||
});
|
||||
});
|
||||
|
||||
it('narrows to the outstanding item when only one of two verifies', () => {
|
||||
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||
// PAN verified, Aadhaar still outstanding — stays open, listing Aadhaar alone.
|
||||
expect(deriveKycExceptionFields(existingException(true, false, both))).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: KYC_EXCEPTION_AADHAAR,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('narrows the other way round too', () => {
|
||||
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||
expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-opening', () => {
|
||||
it('clears a prior resolution so a new exception returns to Pending', () => {
|
||||
// Regression: without exceptionResolved=false the record still matched the Resolved query
|
||||
// and a brand-new exception appeared as already closed.
|
||||
const result = deriveKycExceptionFields({
|
||||
panVerified: false,
|
||||
aadhaarVerified: true,
|
||||
aadhaarApplicable: true,
|
||||
hadException: false,
|
||||
previousReason: 'PAN Card Verification Failed',
|
||||
todayFormatted: TODAY
|
||||
});
|
||||
expect(result.exceptionResolved).toBe(false);
|
||||
expect(result.exceptionResolvedOn).toBeNull();
|
||||
expect(result.isException).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('officer-entered reasons', () => {
|
||||
it('keeps a manual reason alongside the automatic ones', () => {
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(true, true),
|
||||
manualReasons: ['Address proof pending']
|
||||
});
|
||||
expect(result).toEqual({
|
||||
isException: true,
|
||||
exceptionReason: 'Address proof pending',
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a stale automatic reason left in the form once the check passes', () => {
|
||||
// The form still holds 'PAN Card Verification Failed' from a previous load; PAN has since
|
||||
// verified, so it must not survive into the payload.
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(true, true),
|
||||
manualReasons: [KYC_EXCEPTION_PAN]
|
||||
});
|
||||
expect(result.isException).toBe(false);
|
||||
expect(result.exceptionReason).toBeNull();
|
||||
});
|
||||
|
||||
it('does not duplicate a manual reason that matches an outstanding automatic one', () => {
|
||||
const result = deriveKycExceptionFields({
|
||||
...newIndividual(false, true),
|
||||
manualReasons: [KYC_EXCEPTION_PAN]
|
||||
});
|
||||
expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Derives a client's KYC exception state from their verification status.
|
||||
*
|
||||
* A KYC exception is not something an officer should have to remember to tick — it is simply the
|
||||
* fact that a mandatory identity check has not passed. Deriving it means the exception list can
|
||||
* never drift out of step with reality: raising, narrowing and closing all fall out of whether PAN
|
||||
* and Aadhaar are verified at the moment of the save.
|
||||
*
|
||||
* Shared by the client-creation stepper and the client-view KYC tab so both write identical state.
|
||||
*/
|
||||
|
||||
export const KYC_EXCEPTION_PAN = 'PAN Card Verification Failed';
|
||||
export const KYC_EXCEPTION_AADHAAR = 'Aadhaar Verification Failed';
|
||||
|
||||
/** The two reasons this module owns. Anything else on a record was added by an officer by hand. */
|
||||
export const AUTO_MANAGED_KYC_EXCEPTION_REASONS: string[] = [
|
||||
KYC_EXCEPTION_PAN,
|
||||
KYC_EXCEPTION_AADHAAR
|
||||
];
|
||||
|
||||
export interface KycExceptionInput {
|
||||
/** PAN verified against the Income Tax database. Unverified OR simply not entered → exception. */
|
||||
panVerified: boolean;
|
||||
/** Aadhaar verified against UIDAI. Unverified OR simply not entered → exception. */
|
||||
aadhaarVerified: boolean;
|
||||
/**
|
||||
* False for non-individual clients, where no Aadhaar is collected (the payload sends a dummy
|
||||
* masked value). Without this guard every company would carry a permanent Aadhaar exception.
|
||||
*/
|
||||
aadhaarApplicable: boolean;
|
||||
/** Whether an exception is already recorded — decides raise vs. resolve on a clean save. */
|
||||
hadException: boolean;
|
||||
/** Reason currently persisted, kept verbatim when closing so the audit trail survives. */
|
||||
previousReason: string | null;
|
||||
/** Officer-entered reasons beyond the two automatic ones; carried through untouched. */
|
||||
manualReasons?: string[];
|
||||
/** Today in the tenant's date format — stamped as exceptionResolvedOn when closing. */
|
||||
todayFormatted: string;
|
||||
}
|
||||
|
||||
export interface KycExceptionFields {
|
||||
isException: boolean;
|
||||
exceptionReason: string | null;
|
||||
exceptionResolved: boolean;
|
||||
exceptionResolvedOn: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps verification status to the four exception columns the API expects.
|
||||
*
|
||||
* Three outcomes:
|
||||
* - **Something outstanding** → raise/keep the exception, listing only what is still failing. A
|
||||
* record that had both PAN and Aadhaar outstanding and has since verified PAN comes back with
|
||||
* Aadhaar alone, which is what narrows the entry rather than closing it.
|
||||
* - **Nothing outstanding, exception existed** → close it. `exceptionReason` is retained on
|
||||
* purpose: the Resolved tab shows what was resolved, and clearing it would erase the record of
|
||||
* why the exception was ever raised.
|
||||
* - **Nothing outstanding, no exception** → leave every flag off, so a clean client never appears
|
||||
* in either tab.
|
||||
*/
|
||||
export function deriveKycExceptionFields(input: KycExceptionInput): KycExceptionFields {
|
||||
const outstanding: string[] = [];
|
||||
|
||||
if (!input.panVerified) {
|
||||
outstanding.push(KYC_EXCEPTION_PAN);
|
||||
}
|
||||
if (input.aadhaarApplicable && !input.aadhaarVerified) {
|
||||
outstanding.push(KYC_EXCEPTION_AADHAAR);
|
||||
}
|
||||
|
||||
// Officer-added reasons ride along, but the two automatic ones are filtered out first — a stale
|
||||
// 'PAN Card Verification Failed' left in the form must not survive PAN actually being verified.
|
||||
for (const reason of input.manualReasons || []) {
|
||||
const trimmed = (reason || '').trim();
|
||||
if (trimmed && !AUTO_MANAGED_KYC_EXCEPTION_REASONS.includes(trimmed) && !outstanding.includes(trimmed)) {
|
||||
outstanding.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if (outstanding.length > 0) {
|
||||
return {
|
||||
isException: true,
|
||||
exceptionReason: outstanding.join(', '),
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
};
|
||||
}
|
||||
|
||||
if (input.hadException) {
|
||||
return {
|
||||
isException: false,
|
||||
exceptionReason: input.previousReason || null,
|
||||
exceptionResolved: true,
|
||||
exceptionResolvedOn: input.todayFormatted
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isException: false,
|
||||
exceptionReason: null,
|
||||
exceptionResolved: false,
|
||||
exceptionResolvedOn: null
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user