From d584af8e54c9181e096b1337a17a5fa7e8b81076 Mon Sep 17 00:00:00 2001 From: anshul Date: Fri, 16 Oct 2026 17:24:13 +0530 Subject: [PATCH] feat(investment): FD screens - purchase, maturity/premature/renewal, interest entry and reminders --- .../branch-day/branch-day.component.html | 15 + src/app/home/home.component.html | 22 + src/app/home/home.component.ts | 8 +- .../fd-investment.component.html | 935 +++++++++------ .../fd-investment/fd-investment.component.ts | 1019 +++++++++-------- src/app/investment/investment-data.ts | 4 + .../investment-masters.component.html | 11 + .../investment-masters.component.ts | 11 + src/app/investment/investment.service.ts | 197 ++++ 9 files changed, 1356 insertions(+), 866 deletions(-) diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html index 029026b90..b6e64e868 100644 --- a/src/app/cashier/branch-day/branch-day.component.html +++ b/src/app/cashier/branch-day/branch-day.component.html @@ -403,6 +403,21 @@ } + @if (preview?.investmentFdPending?.length) { +
+ notifications_active +
+ Reminder: FD investment entries are pending. + @for (t of preview.investmentFdPending; track t.fdId) { +
+ {{ t.lineType === 'MATURITY' ? 'Maturity' : t.lineType === 'PAYOUT' ? 'Interest payout' : 'Provision' }} + · {{ t.partyName }} · FD {{ t.dealNo }} · {{ t.intDate }} · ₹{{ t.amount | number: '1.2-2' }} +
+ } + Post them under Investment → FD Investment. You can still close the day. +
+
+ } @if (preview?.savingsInterestPending?.length) {
warning diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html index 6cb51900a..a3e8fe8c4 100644 --- a/src/app/home/home.component.html +++ b/src/app/home/home.component.html @@ -64,6 +64,28 @@ } + + @if (pendingFd.length) { + + 🔔 + + {{ pendingFd.length }} FD investment entry(ies) pending + @for (t of pendingFd; track t.fdId) { + + {{ t.lineType === 'MATURITY' ? 'Maturity' : t.lineType === 'PAYOUT' ? 'Interest payout' : 'Provision' }} · + {{ t.partyName }} · FD {{ t.dealNo }} · + {{ t.overdue ? 'overdue since ' + t.intDate : t.postable ? 'due today' : 'due ' + t.intDate }} + + } + + Open FD Investment → + + } +
diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index ac0abd2ed..6b4646feb 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -26,7 +26,7 @@ import { PopoverService } from '../configuration-wizard/popover/popover.service' import { ConfigurationWizardService } from '../configuration-wizard/configuration-wizard.service'; import { SettingsService } from 'app/settings/settings.service'; import { DscService } from 'app/users/dsc/dsc.service'; -import { InvestmentInterestDueDto, InvestmentService } from 'app/investment/investment.service'; +import { InvestmentFdDueDto, InvestmentInterestDueDto, InvestmentService } from 'app/investment/investment.service'; /** Custom Components */ import { NextStepDialogComponent } from '../configuration-wizard/next-step-dialog/next-step-dialog.component'; @@ -122,6 +122,8 @@ export class HomeComponent implements OnInit, AfterViewInit { /** Investment interest lines due now or within two days, shown on the home screen so nobody has to look for them. */ pendingInvestmentInterest: InvestmentInterestDueDto[] = []; + /** FDs placed with other banks: interest vouchers and maturities due now or within two days. */ + pendingFd: InvestmentFdDueDto[] = []; private readonly investmentService = inject(InvestmentService); /** Silent when the user cannot see investments or nothing is due — the strip simply does not render. */ @@ -130,6 +132,10 @@ export class HomeComponent implements OnInit, AfterViewInit { next: (due) => (this.pendingInvestmentInterest = due ?? []), error: () => (this.pendingInvestmentInterest = []) }); + this.investmentService.getFdDue().subscribe({ + next: (due) => (this.pendingFd = due ?? []), + error: () => (this.pendingFd = []) + }); } /** diff --git a/src/app/investment/fd-investment/fd-investment.component.html b/src/app/investment/fd-investment/fd-investment.component.html index 901fcee97..f29fee509 100644 --- a/src/app/investment/fd-investment/fd-investment.component.html +++ b/src/app/investment/fd-investment/fd-investment.component.html @@ -12,17 +12,9 @@
TREASURY · INVESTMENT · FIXED DEPOSIT

FD Investment

- Bank's own Fixed Deposit investments with other banks — Purchase, Interest, Sell/Premature, Write-off and - Part-Payment entries. + Fixed deposits the bank places with other banks — placement, maturity, premature closure and renewal.

- @if (activeTab === 'holdings') { -
- -
- }
@@ -40,7 +32,15 @@ [class.pg-btn--active]="activeTab === 'purchase'" (click)="setTab('purchase')" > - 01 Purchase Entry + 01 FD Purchase Entry + + - - -
- + @if (activeTab === 'holdings') {
- + - - - - + + + + + - - - + + + + + + - @for (h of holdings; track h.certNo) { + @for (f of fds; track f.id) { - - - - - - - - + + + + + + + + + + + + + + } @empty { + + } @@ -119,375 +135,556 @@ } - + @if (activeTab === 'purchase') {
- +
- @if (purchaseForm.tranType === 'RENEWAL') { + @if (selectedParty; as p) { + } - - - - - - - - - + + + + + - - - @if (purchaseForm.matureAmtCalcType === 'GENERAL') { - - - - } @else { - - } - - - - - - - @if (purchaseForm.lien === 'YES') { - + @if (form.payout === 'MATURITY') { } - -
- -
- -
+ + @if (quote; as q) { + +
+ + + + @if (q.payout !== 'MATURITY') { + + } +
+ @if (form.amount && selectedParty && form.bankGlAccountId) { +

+ Voucher on save: Dr {{ selectedParty!.glCode }} {{ selectedParty!.glName }} ₹{{ + form.amount | number: '1.2-2' + }} + / Cr {{ bankGlName(form.bankGlAccountId) }} ₹{{ form.amount | number: '1.2-2' }} +

+ } + + @if (q.limit; as l) { + + @if (l.totalDeposits === null || l.totalDeposits === undefined) { +
+ + +
+
+ +
+ } @else { +
Cert NoPartyTypeFace ValueDeal NoBankCertificateDeal DateAmount RatePurchase Date Maturity DateAccrued InterestMaturity ValueDaysInterestInterest (expected)Maturity Amount StatusVoucher
- {{ h.certNo }} + {{ f.dealNo }} {{ h.party }}{{ h.interestFreq === 'CUMULATIVE' ? 'Cumulative' : 'Payout' }}₹{{ h.faceValue | number: '1.2-2' }}{{ h.interestRate }}%{{ h.purchaseDate }}{{ h.maturityDate }}₹{{ accrued(h) | number: '1.2-2' }}₹{{ maturity(h) | number: '1.2-2' }}{{ f.partyName }}{{ f.certNo }}{{ f.dealDate }}₹{{ f.principal | number: '1.2-2' }}{{ f.intRate }}%{{ f.maturityDate }}{{ f.days }} - {{ h.status }} + {{ payoutLabel(f.payout) }} + @if (f.payout === 'MATURITY') { +
{{ calcLabel(f.calcType) }} + }
₹{{ f.expectedInterest | number: '1.2-2' }}₹{{ f.expectedMaturityAmount | number: '1.2-2' }} + {{ f.status }} + @if (f.closedOnDate) { +
{{ f.closedOnDate }} · interest ₹{{ f.closureInterest | number: '1.2-2' }} + @if (f.closureTds) { + · TDS ₹{{ f.closureTds | number: '1.2-2' }} + } + + } + @if (f.renewedFromDealNo) { +
renewal of {{ f.renewedFromDealNo }} + } +
+ {{ f.voucherNo }} + @if (f.closureVoucherNo) { +
closed: {{ f.closureVoucherNo }} + } +
+ @if (f.status === 'ACTIVE') { + + + } +
No FDs placed yet. Use 01 FD Purchase Entry.
+ + + + + + + + + + + + + + @if (l.partyName) { + + + + + + } + +
LimitAllowedAfter this FD
+ All banks — 20% of total deposits ₹{{ l.totalDeposits | number: '1.2-2' }}
FDs ₹{{ l.fdOutstanding | number: '1.2-2' }} + current accounts with banks ₹{{ + l.bankBalances | number: '1.2-2' + }} +
₹{{ l.grossLimit | number: '1.2-2' }}₹{{ l.grossAfter | number: '1.2-2' }}
{{ l.partyName }} — 5% with one bank₹{{ l.bankLimit | number: '1.2-2' }}₹{{ l.bankAfter | number: '1.2-2' }}
+ } +

+ {{ l.message }} +

+ } + } + @if (quoteError) { +

{{ quoteError }}

+ } +
- - - - - + + +
} - + @if (activeTab === 'interest') {
- +
- - - - @if (interestHolding; as h) { - + @if (schedule; as s) { }
-
- -
-
- } - - - @if (activeTab === 'sell') { -
- -
- - @if (sellHolding; as h) { - - - } - - -
-
- -
-
- } - - - @if (activeTab === 'writeoff') { -
- -
- - @if (writeoffHolding; as h) { - - } - -
-
- -
-
- } - - - @if (activeTab === 'partpayment') { -
- -
- - @if (partPayHolding; as h) { - - } - -
-
- -
-
- } - - @if (activeTab !== 'holdings') { -
- - - - - - - - - - - - - - - @for (t of txns; track t.voucherNo) { + @if (schedule; as s) { +

+ Days are actual / 365. A provision books the interest earned since the last payout (Dr 100603 Interest + Receivable / Cr {{ s.fd.interestGlCode }}); a payout clears what was provisioned and books the rest. Post the + lines in date order, on or after their date. The interest of the last period comes with the maturity (02 + Maturity tab). +

+
DateVoucher NoCert NoTypeParticularsGL PostedAmount
+ - - - - - - - + + + + + + + + + + + @for (r of s.rows; track r.lineType + r.intDate) { + + + + + + + + + + + @if (r.postable && r.lineType === 'PAYOUT') { + + + + } + } + +
{{ t.txnDate }} - {{ t.voucherNo }} - {{ t.certNo }}{{ t.txnType }}{{ t.particulars }}{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}₹{{ t.amount | number: '1.2-2' }}TypeDatePeriodDaysInterestAlready ProvisionedAmountVoucher
{{ lineLabel(r.lineType) }}{{ r.intDate }}{{ r.fromDate }} → {{ r.toDate }}{{ r.days }}₹{{ r.grossInterest | number: '1.2-2' }}{{ r.alreadyBooked ? '₹' + (r.alreadyBooked | number: '1.2-2') : '' }}₹{{ r.amount | number: '1.2-2' }} + @if (r.posted) { + Posted · {{ r.voucherNo }} + @if (r.tds) { +
TDS ₹{{ r.tds | number: '1.2-2' }} + } + } @else if (r.lineType === 'MATURITY') { + + } @else if (r.postable) { + + } @else { + 🔔 Due {{ r.intDate }} + } +
+
+ + + +
+ Leave the amount blank to take the system's interest. Voucher: Dr bank + Dr 100602 TDS / Cr + 100603 (provisioned) + Cr interest income (the rest). +
+ } +
+ +
+
+ } + + + @if (activeTab === 'closure') { +
+ +
+ + @if (closureFd; as f) { + + + + + @if (closure.type === 'PREMATURE') { + } - - + + + @if (closure.type === 'PREMATURE') { + + } + @if (closure.type !== 'RENEWAL') { + + + } + } +
+ + @if (closureFd && closure.type === 'RENEWAL') { + +
+ + + + + + + + + @if (closure.newPayout === 'MATURITY') { + + } + + @if (closureTopUp !== 0) { + + } +
+ } + + @if (closureQuote; as q) { + +
+ + + + +
+ @if (q.difference !== 0 && closure.type !== 'PREMATURE') { +

+ The bank and the system differ by ₹{{ q.difference | number: '1.2-2' }}: the bank may compound differently + (daily / monthly / quarterly). The bank's figure is what gets posted. +

+ } + + + + + + + + + + @for (l of q.lines; track $index) { + + + + + + } @empty { + + + + } + +
Dr / CrGLAmount
{{ l.side }}{{ l.glCode }} {{ l.glName }}₹{{ l.amount | number: '1.2-2' }}
No voucher needed.
+

+ Voucher dated {{ q.closeDate }}. The lines follow the amounts entered above. +

+ } + @if (closureError) { +

{{ closureError }}

+ } + @if (closureFd) { +
+ +
+ } +
+ + +
}
diff --git a/src/app/investment/fd-investment/fd-investment.component.ts b/src/app/investment/fd-investment/fd-investment.component.ts index 04643d0c0..1643c7485 100644 --- a/src/app/investment/fd-investment/fd-investment.component.ts +++ b/src/app/investment/fd-investment/fd-investment.component.ts @@ -6,77 +6,73 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component } from '@angular/core'; +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 { - FD_HOLDINGS, - ALL_TXNS, - PARTY_MASTER, - SUBTYPE_MASTER, - FD_TYPE_OPTIONS, - INT_TYPE_OPTIONS, - LIEN_REASON_MASTER, - INTEREST_INCOME_GL_MASTER, - INTEREST_INCOME_GL_BY_TYPE, - InvestmentHolding, - InvestmentTxn, - InterestFreq, - accruedInterest, - accruedInterestBetween, - canAccrueInterest, - daysToMaturity, - maturityAmount, - addPeriod, - todayDDMMYYYY -} from '../investment-data'; -import { exportWorkbook } from '../investment-export'; + FdCalcType, + FdClosureType, + FdPayout, + InvestmentFdClosureDto, + InvestmentFdInterestRowDto, + InvestmentFdScheduleDto, + InvestmentFdDto, + InvestmentGlAccount, + InvestmentPartyDto, + InvestmentService, + apiErrorMessage +} from '../investment.service'; -type FdTab = 'holdings' | 'purchase' | 'interest' | 'sell' | 'writeoff' | 'partpayment'; +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 { - tranType: 'FRESH' | 'RENEWAL'; - oldCertNo: string; - fdType: string; - /** GENERAL uses the period (Y/M/D) fields; DATEWISE lets the user type the maturity date directly. */ - matureAmtCalcType: 'GENERAL' | 'DATEWISE'; + partyId: number | null; certNo: string; certDate: string; - party: string; - subParty: string; - glAcNo: string; - glAcName: string; - subType: string; - receiptAdvice: 'RECEIPT' | 'ADVICE'; - transactionDate: string; - depositDate: string; - depositAmount: number | null; - interestFreq: InterestFreq; - interestDate: string; - interestRate: number | null; - periodYear: number | null; - periodMonth: number | null; + dealDate: string; + amount: number | null; + intRate: number | null; + periodYears: number | null; + periodMonths: number | null; periodDays: number | null; - /** Only used when matureAmtCalcType === 'DATEWISE'. */ - manualMaturityDate: string; - resolutionNo: string; - crBranchCode: string; - crToAc: string; - crToAcName: string; - intReceivableCode: string; - lien: 'YES' | 'NO'; - lienAmt: number | null; - lienReason: string; - classification: string; - collable: 'YES' | 'NO'; + maturityDate: string; + payout: FdPayout; + calcType: FdCalcType; + bankGlAccountId: number | null; + remarks: string; } /** - * FD (Fixed Deposit) treasury investment screen — Holdings register + Purchase / Interest / - * Sell-Premature / Write-off / Part-Payment entry tabs, modelled on the legacy "Investment - FD - * Purchase Entry" form. Dummy in-memory data only; Save appends to the local holdings/txns - * arrays so the demo feels interactive without any backend. + * 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', @@ -92,463 +88,494 @@ interface FdPurchaseForm { DateDdmmyyyyPickerComponent ] }) -export class FdInvestmentComponent { +export class FdInvestmentComponent implements OnInit { + private readonly investmentService = inject(InvestmentService); + private readonly route = inject(ActivatedRoute); + activeTab: FdTab = 'holdings'; - asOnDate = todayDDMMYYYY(); + fds: InvestmentFdDto[] = []; + fdParties: InvestmentPartyDto[] = []; + /** Our bank accounts (current accounts under 100200) the money can be paid from. */ + bankGlOptions: InvestmentGlAccount[] = []; - holdings: InvestmentHolding[] = [...FD_HOLDINGS]; - txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'FD'); - - readonly partyOptions = PARTY_MASTER; - readonly subTypeOptions = SUBTYPE_MASTER; - readonly fdTypeOptions = FD_TYPE_OPTIONS; - readonly intTypeOptions = INT_TYPE_OPTIONS; - readonly lienReasonOptions = LIEN_REASON_MASTER; - readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER; - readonly branchOptions = [ - 'HEAD OFFICE', - 'BRANCH 1', - 'BRANCH 2' + 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' } ]; - generating = false; + 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 | undefined; - purchaseForm: FdPurchaseForm = this.blankPurchaseForm(); - /** Certificate No typed into the Purchase Entry's Search box (legacy "Search" button). */ - searchCertNo = ''; - - interestCertNo = ''; - interestFromDate = addPeriod(this.asOnDate, 0, -3); - interestToDate = this.asOnDate; - interestGlCode = INTEREST_INCOME_GL_BY_TYPE.FD; - sellCertNo = ''; - sellDate = this.asOnDate; - sellRemark = ''; - writeoffCertNo = ''; - writeoffReason = ''; - partPayCertNo = ''; - partPayAmount: number | null = null; + ngOnInit(): void { + this.investmentService.getParties().subscribe({ + next: (ps) => (this.fdParties = ps.filter((p) => p.bondType === 'FD' && p.active)), + 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; - } - - get activeHoldings(): InvestmentHolding[] { - return this.holdings.filter((h) => h.status === 'ACTIVE'); - } - - private nextCertNo(): string { - const nums = this.holdings.map((h) => parseInt(h.certNo, 10)).filter((n) => !isNaN(n)); - return String((nums.length ? Math.max(...nums) : 15619) + 1); - } - - private blankPurchaseForm(): FdPurchaseForm { - return { - tranType: 'FRESH', - oldCertNo: '', - fdType: 'CUMMULATIVE', - matureAmtCalcType: 'GENERAL', - certNo: this.nextCertNo(), - certDate: this.asOnDate, - party: '', - subParty: '', - glAcNo: '320020', - glAcName: 'FIXED DEPO. CO-OPER. BANK MAIN', - subType: 'NON SLR', - receiptAdvice: 'RECEIPT', - transactionDate: this.asOnDate, - depositDate: this.asOnDate, - depositAmount: null, - interestFreq: 'CUMULATIVE', - interestDate: this.asOnDate, - interestRate: null, - periodYear: 2, - periodMonth: 0, - periodDays: 0, - manualMaturityDate: '', - resolutionNo: '', - crBranchCode: 'HEAD OFFICE', - crToAc: '310070', - crToAcName: 'CURRENT A/C CO-OP. BANK MAIN BR.41', - intReceivableCode: '320020', - lien: 'NO', - lienAmt: null, - lienReason: '', - classification: '', - collable: 'NO' - }; - } - - get purchaseMaturityDate(): string { - const f = this.purchaseForm; - if (f.matureAmtCalcType === 'DATEWISE') { - return f.manualMaturityDate; + if (tab === 'purchase') { + this.refreshQuote(); } - if (!f.depositDate) { - return ''; - } - const base = addPeriod(f.depositDate, f.periodYear || 0, f.periodMonth || 0); - return f.periodDays ? addPeriod(base, 0, 0, f.periodDays) : base; } - get purchaseMaturityAmount(): number { - const f = this.purchaseForm; - if (!f.depositAmount || !f.interestRate || !this.purchaseMaturityDate) { - return 0; - } - return maturityAmount({ - certNo: f.certNo, - instrumentType: 'FD', - scriptCode: '', - scriptName: '', - party: f.party, - subType: f.subType, - faceValue: f.depositAmount, - purchaseRate: 100, - units: 1, - purchaseDate: f.depositDate, - maturityDate: this.purchaseMaturityDate, - interestRate: f.interestRate, - interestFreq: f.interestFreq, - glCode: f.glAcNo, - glName: '', - crToAc: f.crToAc, - brokerCode: '', - status: 'ACTIVE' + 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 (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 = ''; + const params: Record = { + 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 (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.purchaseForm; - if (!f.party || !f.depositAmount || !f.interestRate || !this.purchaseMaturityDate) { - this.toast('Please fill Party, Deposit Amount, Interest Rate and Period.'); - return; - } - const holding: InvestmentHolding = { - certNo: f.certNo, - instrumentType: 'FD', - scriptCode: f.interestFreq === 'CUMULATIVE' ? 'FD-CUM' : 'FD-MIP', - scriptName: f.fdType ? `${f.fdType} Fixed Deposit` : 'Fixed Deposit', - party: f.party, - subType: f.subType, - faceValue: f.depositAmount, - purchaseRate: 100, - units: 1, - purchaseDate: f.depositDate, - maturityDate: this.purchaseMaturityDate, - interestRate: f.interestRate, - interestFreq: f.interestFreq, - glCode: f.glAcNo, - glName: f.glAcName, - crToAc: f.crToAc, - brokerCode: '', - status: 'ACTIVE' - }; - this.holdings = [ - holding, - ...this.holdings - ]; - this.txns = [ - { - certNo: holding.certNo, - instrumentType: 'FD', - txnType: 'PURCHASE', - voucherNo: `INV/PUR/FD-${holding.certNo}`, - txnDate: f.transactionDate, - amount: holding.faceValue, - particulars: `${f.tranType === 'RENEWAL' ? 'Renewal' : 'Fresh'} FD Purchase - ${holding.party}` - }, - ...this.txns - ]; - this.toast(`FD Certificate No. ${holding.certNo} saved successfully.`); - this.purchaseForm = this.blankPurchaseForm(); - this.activeTab = 'holdings'; - } - - cancelPurchase(): void { - this.purchaseForm = this.blankPurchaseForm(); - this.toast('Entry cancelled — form reset.'); - } - - /** Legacy "Search" button — loads an existing certificate's values back into the form. */ - searchPurchase(): void { - const h = this.holdings.find((x) => x.certNo === this.searchCertNo.trim()); - if (!h) { - this.toast(`Certificate No. ${this.searchCertNo} not found.`); - return; - } - this.purchaseForm = { - ...this.blankPurchaseForm(), - tranType: 'FRESH', - certNo: h.certNo, - certDate: h.purchaseDate, - party: h.party, - subType: h.subType, - glAcNo: h.glCode, - glAcName: h.glName, - transactionDate: h.purchaseDate, - depositDate: h.purchaseDate, - depositAmount: h.faceValue, - interestFreq: h.interestFreq, - interestDate: h.purchaseDate, - interestRate: h.interestRate, - matureAmtCalcType: 'DATEWISE', - manualMaturityDate: h.maturityDate, - crToAc: h.crToAc - }; - this.toast(`Certificate ${h.certNo} loaded.`); - } - - /** Legacy "Drop" button — removes the loaded certificate from the register. */ - dropPurchase(): void { - const certNo = this.purchaseForm.certNo; - const exists = this.holdings.some((h) => h.certNo === certNo); - if (!exists) { - this.toast('Nothing to drop — this certificate is not yet saved.'); - return; - } - this.holdings = this.holdings.filter((h) => h.certNo !== certNo); - this.txns = this.txns.filter((t) => t.certNo !== certNo); - this.purchaseForm = this.blankPurchaseForm(); - this.toast(`Certificate ${certNo} dropped.`); - } - - /** Legacy "Exit" button — closes the entry form and returns to the register. */ - exitPurchase(): void { - this.purchaseForm = this.blankPurchaseForm(); - this.activeTab = 'holdings'; - } - - // -------------------- Interest -------------------- - /** Certificates eligible for accrual-basis posting — same rule as the Investment Interest - * module: a Non-Performing holding never appears here, only through Interest Receive. */ - get interestEligibleHoldings(): InvestmentHolding[] { - return this.activeHoldings.filter((h) => canAccrueInterest(h)); - } - - get interestHolding(): InvestmentHolding | undefined { - return this.holdings.find((h) => h.certNo === this.interestCertNo); - } - - /** Interest accrued strictly within [interestFromDate, interestToDate] — a period-based - * Interest Calculation & Post run, same basis as the Investment Interest module. */ - interestAccrued(h: InvestmentHolding): number { - return accruedInterestBetween(h, this.interestFromDate || h.purchaseDate, this.interestToDate || this.asOnDate); - } - - glName(glCode: string): string { - return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode; - } - - postInterest(): void { - const h = this.interestHolding; - if (!h) { - this.toast('Select a certificate first.'); - return; - } - const amt = this.interestAccrued(h); - if (amt <= 0) { - this.toast('No interest due to be posted for this period.'); - return; - } - const toDate = this.interestToDate || this.asOnDate; - const fromDate = this.interestFromDate || toDate; - const glCode = this.interestGlCode; - const glName = this.glName(glCode); - h.interestPosted = (h.interestPosted ?? 0) + amt; - h.interestPostedTillDate = toDate; - this.interestFromDate = toDate; - this.txns = [ - { - certNo: h.certNo, - instrumentType: 'FD', - txnType: 'INTEREST', - voucherNo: `INV/INT/FD-${h.certNo}-${this.txns.length + 1}`, - txnDate: toDate, - amount: amt, - particulars: `Interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.party} (${h.interestFreq})`, - glCode, - glName - }, - ...this.txns - ]; - this.toast( - `Interest of ₹${amt.toLocaleString('en-IN')} posted to ${glCode} - ${glName} for Certificate ${h.certNo}.` - ); - this.interestCertNo = ''; - } - - // -------------------- Sell / Premature -------------------- - get sellHolding(): InvestmentHolding | undefined { - return this.holdings.find((h) => h.certNo === this.sellCertNo); - } - - saveSell(): void { - const h = this.sellHolding; - if (!h || !this.sellDate) { - this.toast('Select a certificate and sell/premature date.'); - return; - } - const payout = h.faceValue + accruedInterest(h, this.sellDate); - h.status = 'SOLD'; - this.txns = [ - { - certNo: h.certNo, - instrumentType: 'FD', - txnType: 'SELL', - voucherNo: `INV/SEL/FD-${h.certNo}`, - txnDate: this.sellDate, - amount: payout, - particulars: `Premature closure - ${h.party}${this.sellRemark ? ' - ' + this.sellRemark : ''}` - }, - ...this.txns - ]; - this.toast(`Certificate ${h.certNo} closed. Payout ₹${payout.toLocaleString('en-IN')}.`); - this.sellCertNo = ''; - this.sellRemark = ''; - this.activeTab = 'holdings'; - } - - // -------------------- Write-off -------------------- - get writeoffHolding(): InvestmentHolding | undefined { - return this.holdings.find((h) => h.certNo === this.writeoffCertNo); - } - - saveWriteoff(): void { - const h = this.writeoffHolding; - if (!h || !this.writeoffReason) { - this.toast('Select a certificate and enter a write-off reason.'); - return; - } - h.status = 'WRITTEN_OFF'; - this.txns = [ - { - certNo: h.certNo, - instrumentType: 'FD', - txnType: 'WRITE_OFF', - voucherNo: `INV/WOF/FD-${h.certNo}`, - txnDate: this.asOnDate, - amount: h.faceValue, - particulars: `Written off - ${this.writeoffReason}` - }, - ...this.txns - ]; - this.toast(`Certificate ${h.certNo} written off.`); - this.writeoffCertNo = ''; - this.writeoffReason = ''; - this.activeTab = 'holdings'; - } - - // -------------------- Part Payment -------------------- - get partPayHolding(): InvestmentHolding | undefined { - return this.holdings.find((h) => h.certNo === this.partPayCertNo); - } - - savePartPayment(): void { - const h = this.partPayHolding; - if (!h || !this.partPayAmount || this.partPayAmount <= 0 || this.partPayAmount >= h.faceValue) { - this.toast('Select a certificate and enter a valid part-payment amount (less than face value).'); - return; - } - h.faceValue -= this.partPayAmount; - this.txns = [ - { - certNo: h.certNo, - instrumentType: 'FD', - txnType: 'PART_PAYMENT', - voucherNo: `INV/PP/FD-${h.certNo}-${this.txns.length + 1}`, - txnDate: this.asOnDate, - amount: this.partPayAmount, - particulars: `Part payment withdrawn - ${h.party}, balance ₹${h.faceValue.toLocaleString('en-IN')}` - }, - ...this.txns - ]; - this.toast(`Part payment of ₹${this.partPayAmount.toLocaleString('en-IN')} recorded for Certificate ${h.certNo}.`); - this.partPayCertNo = ''; - this.partPayAmount = null; - this.activeTab = 'holdings'; - } - - // -------------------- Helpers -------------------- - accrued(h: InvestmentHolding): number { - return accruedInterest(h, this.asOnDate); - } - - maturity(h: InvestmentHolding): number { - return maturityAmount(h); - } - - daysLeft(h: InvestmentHolding): number { - return daysToMaturity(h, this.asOnDate); - } - - statusClass(h: InvestmentHolding): string { - return `inv-badge inv-badge--${h.status.toLowerCase()}`; - } - - // -------------------- Export to Excel (Holdings register) -------------------- - async exportExcel(): Promise { - this.generating = true; - try { - await exportWorkbook( - [ - { - sheetName: 'FD Holdings', - title: 'FD Holdings Register', - subtitle: `As on ${this.asOnDate}`, - headers: [ - 'Cert No', - 'Party', - 'Type', - 'Face Value', - 'Rate', - 'Frequency', - 'Purchase Date', - 'Maturity Date', - 'Accrued Interest', - 'Maturity Value', - 'Status' - ], - widths: [ - 14, - 28, - 18, - 16, - 10, - 14, - 14, - 14, - 16, - 16, - 12 - ], - rows: this.holdings.map((h) => [ - h.certNo, - h.party, - h.interestFreq === 'CUMULATIVE' ? 'Cumulative' : 'Payout', - h.faceValue, - h.interestRate, - h.interestFreq, - h.purchaseDate, - h.maturityDate, - this.accrued(h), - this.maturity(h), - h.status - ]) - } - ], - `FD-Holdings-${this.asOnDate}` + 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.' ); - this.toast('FD Holdings exported.'); - } catch (err) { - console.error('FD Holdings export failed', err); - this.toast('Could not generate the workbook — please retry.'); - } finally { - this.generating = false; + 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.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 = { + lineType: r.lineType, + intDate: r.intDate, + locale: 'en', + dateFormat: 'dd-MM-yyyy' + }; + if (r.lineType === 'PAYOUT') { + if (this.payoutReceived) { + body['receivedAmount'] = this.payoutReceived; + } + if (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; + } + const params: Record = { type: c.type }; + if (c.type === 'PREMATURE' && c.closeDate) { + params['closeDate'] = c.closeDate; + } + if (c.receivedAmount) { + params['receivedAmount'] = c.receivedAmount; + } + if (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 = 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 { @@ -557,6 +584,6 @@ export class FdInvestmentComponent { if (this.toastTimer) { clearTimeout(this.toastTimer); } - this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200); + this.toastTimer = setTimeout(() => (this.toastVisible = false), 4000); } } diff --git a/src/app/investment/investment-data.ts b/src/app/investment/investment-data.ts index 77d7e95a7..0310b6698 100644 --- a/src/app/investment/investment-data.ts +++ b/src/app/investment/investment-data.ts @@ -1906,6 +1906,10 @@ export interface PartyMaster { glAccountId?: number; glCode?: string; glName?: string; + /** Interest income GL of the FDs placed with this bank (Bond Type FD). */ + interestGlAccountId?: number; + interestGlCode?: string; + interestGlName?: string; rbiCode?: string; /** Exposure limit for this party (₹). */ limit?: number | null; diff --git a/src/app/investment/investment-masters/investment-masters.component.html b/src/app/investment/investment-masters/investment-masters.component.html index f4e835753..10b5458ca 100644 --- a/src/app/investment/investment-masters/investment-masters.component.html +++ b/src/app/investment/investment-masters/investment-masters.component.html @@ -162,6 +162,17 @@ } + @if (partyForm.bondType === 'FD') { + + }