Files
cbsnexora/src/app/investment/fd-investment/fd-investment.component.ts
T

625 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { addPeriod, parseDDMMYYYY, todayDDMMYYYY } from '../investment-data';
import {
FdCalcType,
FdClosureType,
FdPayout,
InvestmentFdClosureDto,
InvestmentFdInterestRowDto,
InvestmentFdScheduleDto,
InvestmentFdDto,
InvestmentGlAccount,
InvestmentPartyDto,
InvestmentService,
apiErrorMessage
} from '../investment.service';
type FdTab = 'holdings' | 'purchase' | 'closure' | 'interest';
interface FdClosureForm {
fdId: number | null;
type: FdClosureType;
closeDate: string;
/** Maturity / premature: money credited by the bank. Renewal: the maturity amount as per the bank. */
receivedAmount: number | null;
tds: number | null;
penalAmount: number | null;
principalBankGlAccountId: number | null;
interestBankGlAccountId: number | null;
newCertNo: string;
newCertDate: string;
newIntRate: number | null;
newPeriodYears: number | null;
newPeriodMonths: number | null;
newPeriodDays: number | null;
newMaturityDate: string;
newPayout: FdPayout;
newCalcType: FdCalcType;
renewalAmount: number | null;
diffBankGlAccountId: number | null;
remarks: string;
}
interface FdPurchaseForm {
partyId: number | null;
certNo: string;
certDate: string;
dealDate: string;
amount: number | null;
intRate: number | null;
periodYears: number | null;
periodMonths: number | null;
periodDays: number | null;
maturityDate: string;
payout: FdPayout;
calcType: FdCalcType;
bankGlAccountId: number | null;
remarks: string;
}
/**
* FDs the bank places with other banks (Jitendra Sir): the bank is an FD party with its own FD GL and interest GL;
* placing an FD posts Dr the bank's FD GL / Cr our bank GL at once. The RBI limits (20% of total deposits as on the
* previous 31 March with all banks, 5% with one bank) are checked before saving.
*/
@Component({
selector: 'mifosx-fd-investment',
templateUrl: './fd-investment.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../investment-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
DateDdmmyyyyPickerComponent
]
})
export class FdInvestmentComponent implements OnInit {
private readonly investmentService = inject(InvestmentService);
private readonly route = inject(ActivatedRoute);
activeTab: FdTab = 'holdings';
fds: InvestmentFdDto[] = [];
fdParties: InvestmentPartyDto[] = [];
/** Our bank accounts (current accounts under 100200) the money can be paid from. */
bankGlOptions: InvestmentGlAccount[] = [];
readonly payoutOptions: Array<{ value: FdPayout; label: string }> = [
{ value: 'MATURITY', label: 'At Maturity' },
{ value: 'HALF_YEARLY', label: 'Half-Yearly' },
{ value: 'YEARLY', label: 'Yearly' }
];
readonly calcTypeOptions: Array<{ value: FdCalcType; label: string }> = [
{ value: 'QUARTERLY', label: 'Compound – Quarterly' },
{ value: 'MONTHLY', label: 'Compound – Monthly' },
{ value: 'HALF_YEARLY', label: 'Compound – Half-Yearly' },
{ value: 'YEARLY', label: 'Compound – Yearly' },
{ value: 'SIMPLE', label: 'Simple Interest' }
];
form: FdPurchaseForm = this.blankForm();
quote: InvestmentFdDto | null = null;
quoteError = '';
saving = false;
// 03 Interest Entry
interestFdId: number | null = null;
schedule: InvestmentFdScheduleDto | null = null;
payoutReceived: number | null = null;
payoutTds: number | null = null;
payoutBankGlAccountId: number | null = null;
postingInterest = false;
closure: FdClosureForm = this.blankClosure();
closureQuote: InvestmentFdClosureDto | null = null;
closureError = '';
closing = false;
/** Total deposits as on the 31 March the limits use, when it still has to be entered. */
baseAmount: number | null = null;
savingBase = false;
toastMsg = '';
toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined;
ngOnInit(): void {
this.investmentService.getParties().subscribe({
next: (ps) => (this.fdParties = ps.filter((p) => p.bondType === 'FD' && p.active && !!p.interestGlAccountId)),
error: (err) => this.toast(`Could not load FD banks: ${apiErrorMessage(err)}`)
});
this.investmentService.getAssetGlAccounts().subscribe({
next: (gls) => (this.bankGlOptions = gls.filter((g) => g.glCode.startsWith('1002'))),
error: (err) => this.toast(`Could not load bank GLs: ${apiErrorMessage(err)}`)
});
this.loadFds();
// Opened from a reminder (home screen / day close): straight to the FD's interest lines.
const fdId = Number(this.route.snapshot.queryParamMap.get('fd'));
if (this.route.snapshot.queryParamMap.get('tab') === 'interest') {
this.activeTab = 'interest';
if (fdId) {
this.interestFdId = fdId;
this.loadSchedule();
}
}
}
setTab(tab: FdTab): void {
this.activeTab = tab;
if (tab === 'purchase') {
this.refreshQuote();
} else if (tab === 'closure' && this.closure.fdId) {
// Interest may have been posted on the other tab meanwhile: work the closure out again.
this.refreshClosureQuote();
} else if (tab === 'interest' && this.interestFdId) {
this.loadSchedule();
}
}
/** A complete DD-MM-YYYY date: half-typed dates are not sent to the server. */
private static fullDate(d: string | null | undefined): boolean {
return !!d && /^\d{2}-\d{2}-\d{4}$/.test(d);
}
private loadFds(): void {
this.investmentService.getFds().subscribe({
next: (list) => (this.fds = list),
error: (err) => this.toast(`Could not load FDs: ${apiErrorMessage(err)}`)
});
}
get selectedParty(): InvestmentPartyDto | undefined {
return this.fdParties.find((p) => p.id === this.form.partyId);
}
get activeFds(): InvestmentFdDto[] {
return this.fds.filter((f) => f.status === 'ACTIVE');
}
get totalPlaced(): number {
return this.activeFds.reduce((sum, f) => sum + (f.principal ?? 0), 0);
}
payoutLabel(p?: string): string {
return this.payoutOptions.find((o) => o.value === p)?.label ?? p ?? '';
}
calcLabel(c?: string): string {
return this.calcTypeOptions.find((o) => o.value === c)?.label ?? c ?? '';
}
// -------------------- Purchase --------------------
/** Period (years / months / days) sets the maturity date; the maturity date can also be typed directly. */
onPeriodChange(): void {
const f = this.form;
if (FdInvestmentComponent.fullDate(f.dealDate) && (f.periodYears || f.periodMonths || f.periodDays)) {
f.maturityDate = addPeriod(f.dealDate, f.periodYears ?? 0, f.periodMonths ?? 0, f.periodDays ?? 0);
}
this.refreshQuote();
}
/** Asks the backend for the interest, maturity amount and the RBI limit position of the FD being entered. */
refreshQuote(): void {
const f = this.form;
this.quoteError = '';
if (f.dealDate && !FdInvestmentComponent.fullDate(f.dealDate)) {
return;
}
const params: Record<string, string | number> = {
dealDate: f.dealDate || todayDDMMYYYY(),
payout: f.payout,
calcType: f.calcType
};
if (f.partyId) {
params['partyId'] = f.partyId;
}
if (f.amount) {
params['amount'] = f.amount;
}
if (f.intRate) {
params['intRate'] = f.intRate;
}
if (FdInvestmentComponent.fullDate(f.maturityDate)) {
params['maturityDate'] = f.maturityDate;
}
this.investmentService.quoteFd(params).subscribe({
next: (q) => {
this.quote = q;
if (q.limit && q.limit.totalDeposits == null && this.baseAmount == null) {
this.baseAmount = q.limit.glTotalDeposits || null;
}
},
error: (err) => {
this.quote = null;
this.quoteError = apiErrorMessage(err);
}
});
}
saveBase(): void {
const asOn = this.quote?.limit?.asOnDate;
if (!asOn || !this.baseAmount || this.baseAmount <= 0) {
this.toast('Enter the total deposits as on 31 March.');
return;
}
this.savingBase = true;
this.investmentService
.setDepositBase({ asOnDate: asOn, totalDeposits: this.baseAmount, locale: 'en', dateFormat: 'dd-MM-yyyy' })
.subscribe({
next: () => {
this.savingBase = false;
this.toast(`Total deposits as on ${asOn} saved.`);
this.refreshQuote();
},
error: (err) => {
this.savingBase = false;
this.toast(apiErrorMessage(err));
}
});
}
savePurchase(): void {
const f = this.form;
if (
!f.partyId ||
!f.certNo.trim() ||
!f.dealDate ||
!f.amount ||
!f.intRate ||
!f.maturityDate ||
!f.bankGlAccountId
) {
this.toast(
'Enter the bank, certificate no, deal date, amount, rate, maturity date and the bank account paid from.'
);
return;
}
if (this.quote?.limit && !this.quote.limit.withinLimits) {
this.toast(this.quote.limit.message);
return;
}
this.saving = true;
this.investmentService
.createFd({
partyId: f.partyId,
certNo: f.certNo.trim(),
...(f.certDate ? { certDate: f.certDate } : {}),
dealDate: f.dealDate,
amount: f.amount,
intRate: f.intRate,
maturityDate: f.maturityDate,
payout: f.payout,
calcType: f.calcType,
bankGlAccountId: f.bankGlAccountId,
...(f.remarks.trim() ? { remarks: f.remarks.trim() } : {}),
locale: 'en',
dateFormat: 'dd-MM-yyyy'
})
.subscribe({
next: (res) => {
this.saving = false;
this.toast(
`FD ${res.changes?.dealNo ?? ''} placed — voucher ${res.changes?.voucherNo ?? res.resourceIdentifier ?? ''}.`
);
this.form = this.blankForm();
this.quote = null;
this.loadFds();
this.setTab('holdings');
},
error: (err) => {
this.saving = false;
this.toast(apiErrorMessage(err));
}
});
}
resetPurchase(): void {
this.quoteError = '';
this.form = this.blankForm();
this.refreshQuote();
}
// -------------------- Interest Entry --------------------
openInterest(f: InvestmentFdDto): void {
this.interestFdId = f.id ?? null;
this.setTab('interest');
this.loadSchedule();
}
loadSchedule(): void {
this.schedule = null;
this.payoutReceived = null;
this.payoutTds = null;
if (!this.interestFdId) {
return;
}
this.investmentService.getFdSchedule(this.interestFdId).subscribe({
next: (s) => {
this.schedule = s;
this.payoutBankGlAccountId = s.fd.bankGlAccountId ?? null;
},
error: (err) => this.toast(apiErrorMessage(err))
});
}
lineLabel(t: string): string {
return t === 'PROVISION' ? 'Provision' : t === 'PAYOUT' ? 'Interest Payout' : 'Maturity';
}
/** Posts the next line: a provision as worked out, a payout with the bank's amount and TDS if entered. */
postInterest(r: InvestmentFdInterestRowDto): void {
const fdId = this.interestFdId;
if (!fdId) {
return;
}
const body: Record<string, string | number> = {
lineType: r.lineType,
intDate: r.intDate,
locale: 'en',
dateFormat: 'dd-MM-yyyy'
};
if (r.lineType === 'PAYOUT') {
if (this.payoutReceived) {
body['receivedAmount'] = this.payoutReceived;
}
// Blank TDS: the system works it out at the bank's rate; an entered figure (even 0) is used as it is.
if (entered(this.payoutTds)) {
body['tds'] = this.payoutTds;
}
if (this.payoutBankGlAccountId) {
body['bankGlAccountId'] = this.payoutBankGlAccountId;
}
}
this.postingInterest = true;
this.investmentService.postFdInterest(fdId, body).subscribe({
next: (res) => {
this.postingInterest = false;
this.toast(`${this.lineLabel(r.lineType)} of ${r.intDate} posted — voucher ${res.resourceIdentifier ?? ''}.`);
this.loadSchedule();
},
error: (err) => {
this.postingInterest = false;
this.toast(apiErrorMessage(err));
}
});
}
// -------------------- Maturity / Premature / Renewal --------------------
get closureFd(): InvestmentFdDto | undefined {
return this.fds.find((f) => f.id === this.closure.fdId);
}
/** Maturity and renewal happen on or after the maturity date; before it only a premature closure is possible. */
get closureFdMatured(): boolean {
const f = this.closureFd;
return !!f?.maturityDate && parseDDMMYYYY(f.maturityDate) <= parseDDMMYYYY(todayDDMMYYYY());
}
/** The renewal top-up (positive) or amount taken out (negative), as worked out by the backend. */
get closureTopUp(): number {
return this.closureQuote?.topUp ?? 0;
}
openClosure(f: InvestmentFdDto): void {
this.closure = this.blankClosure();
this.closure.fdId = f.id ?? null;
this.onClosureFdChange();
this.setTab('closure');
}
onClosureFdChange(): void {
const f = this.closureFd;
const c = this.closure;
c.receivedAmount = null;
c.renewalAmount = null;
c.type = this.closureFdMatured ? 'MATURITY' : 'PREMATURE';
c.principalBankGlAccountId = f?.bankGlAccountId ?? null;
c.interestBankGlAccountId = f?.bankGlAccountId ?? null;
c.newPayout = f?.payout ?? 'MATURITY';
c.newCalcType = f?.calcType ?? 'QUARTERLY';
this.refreshClosureQuote();
}
onNewPeriodChange(): void {
const c = this.closure;
const start = this.closureFd?.maturityDate;
if (start && (c.newPeriodYears || c.newPeriodMonths || c.newPeriodDays)) {
c.newMaturityDate = addPeriod(start, c.newPeriodYears ?? 0, c.newPeriodMonths ?? 0, c.newPeriodDays ?? 0);
}
}
refreshClosureQuote(): void {
const c = this.closure;
this.closureError = '';
if (!c.fdId) {
this.closureQuote = null;
return;
}
if (c.type === 'PREMATURE' && c.closeDate && !FdInvestmentComponent.fullDate(c.closeDate)) {
return;
}
const params: Record<string, string | number> = { type: c.type };
if (c.type === 'PREMATURE' && c.closeDate) {
params['closeDate'] = c.closeDate;
}
if (c.type === 'RENEWAL') {
if (c.diffBankGlAccountId) {
params['diffBankGlAccountId'] = c.diffBankGlAccountId;
}
} else {
if (c.principalBankGlAccountId) {
params['principalBankGlAccountId'] = c.principalBankGlAccountId;
}
if (c.interestBankGlAccountId) {
params['interestBankGlAccountId'] = c.interestBankGlAccountId;
}
}
if (c.receivedAmount) {
params['receivedAmount'] = c.receivedAmount;
}
if (entered(c.tds)) {
params['tds'] = c.tds;
}
if (c.type === 'RENEWAL' && c.renewalAmount) {
params['renewalAmount'] = c.renewalAmount;
}
this.investmentService.closureQuoteFd(c.fdId, params).subscribe({
next: (q) => (this.closureQuote = q),
error: (err) => {
this.closureQuote = null;
this.closureError = apiErrorMessage(err);
}
});
}
saveClosure(): void {
const c = this.closure;
const f = this.closureFd;
if (!f?.id) {
this.toast('Select the FD.');
return;
}
const base = { locale: 'en', dateFormat: 'dd-MM-yyyy', ...(c.remarks.trim() ? { remarks: c.remarks.trim() } : {}) };
const tds = entered(c.tds) ? { tds: c.tds } : {};
let body: object;
if (c.type === 'RENEWAL') {
if (!c.newCertNo.trim() || !c.newIntRate || !c.newMaturityDate) {
this.toast('Enter the new certificate no, interest rate and maturity date.');
return;
}
const topUp = this.closureTopUp;
if (topUp !== 0 && !c.diffBankGlAccountId) {
this.toast(
topUp > 0 ? 'Choose the bank the extra amount is paid from.' : 'Choose the bank the amount taken out goes to.'
);
return;
}
body = {
...base,
...tds,
newCertNo: c.newCertNo.trim(),
...(c.newCertDate ? { newCertDate: c.newCertDate } : {}),
newIntRate: c.newIntRate,
newMaturityDate: c.newMaturityDate,
newPayout: c.newPayout,
newCalcType: c.newCalcType,
...(c.receivedAmount ? { maturityAmount: c.receivedAmount } : {}),
...(c.renewalAmount ? { renewalAmount: c.renewalAmount } : {}),
...(topUp !== 0 && c.diffBankGlAccountId ? { diffBankGlAccountId: c.diffBankGlAccountId } : {})
};
} else {
if (c.type === 'PREMATURE' && (!c.closeDate || !c.receivedAmount)) {
this.toast('Enter the closure date and the amount received from the bank.');
return;
}
body = {
...base,
...tds,
...(c.type === 'PREMATURE' ? { closeDate: c.closeDate } : {}),
...(c.receivedAmount ? { receivedAmount: c.receivedAmount } : {}),
...(c.type === 'PREMATURE' && c.penalAmount ? { penalAmount: c.penalAmount } : {}),
...(c.principalBankGlAccountId ? { principalBankGlAccountId: c.principalBankGlAccountId } : {}),
...(c.interestBankGlAccountId ? { interestBankGlAccountId: c.interestBankGlAccountId } : {})
};
}
this.closing = true;
this.investmentService.closeFd(f.id, c.type, body).subscribe({
next: (res) => {
this.closing = false;
const what =
c.type === 'RENEWAL'
? `renewed as ${res.changes?.dealNo ?? ''}`
: c.type === 'PREMATURE'
? 'closed'
: 'matured';
this.toast(`FD ${f.dealNo} ${what} — voucher ${res.changes?.voucherNo ?? res.resourceIdentifier ?? ''}.`);
this.closure = this.blankClosure();
this.closureQuote = null;
this.loadFds();
this.setTab('holdings');
},
error: (err) => {
this.closing = false;
this.toast(apiErrorMessage(err));
}
});
}
private blankClosure(): FdClosureForm {
return {
fdId: null,
type: 'MATURITY',
closeDate: todayDDMMYYYY(),
receivedAmount: null,
tds: null,
penalAmount: null,
principalBankGlAccountId: null,
interestBankGlAccountId: null,
newCertNo: '',
newCertDate: '',
newIntRate: null,
newPeriodYears: null,
newPeriodMonths: null,
newPeriodDays: null,
newMaturityDate: '',
newPayout: 'MATURITY',
newCalcType: 'QUARTERLY',
renewalAmount: null,
diffBankGlAccountId: null,
remarks: ''
};
}
bankGlName(id: number | null): string {
const g = this.bankGlOptions.find((x) => x.id === id);
return g ? `${g.glCode} ${g.name}` : '';
}
private blankForm(): FdPurchaseForm {
return {
partyId: null,
certNo: '',
certDate: '',
dealDate: todayDDMMYYYY(),
amount: null,
intRate: null,
periodYears: null,
periodMonths: null,
periodDays: null,
maturityDate: '',
payout: 'MATURITY',
calcType: 'QUARTERLY',
bankGlAccountId: null,
remarks: ''
};
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 4000);
}
}
/** A number box the user filled in (0 counts; blank does not). */
function entered(v: number | null | undefined | string): boolean {
return v !== null && v !== undefined && v !== '';
}