feat: implement share account actions, view components, and pending checker tasks
This commit is contained in:
@@ -7,30 +7,63 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, Input, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { SharesService } from 'app/shares/shares.service';
|
||||
import { CashierService } from 'app/cashier/cashier.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* Apply Shares Component
|
||||
* Apply Additional Shares.
|
||||
*
|
||||
* A member buying a further tranche of shares, which — like the account's first application — is
|
||||
* only a request until it is approved. Two things have to be captured that the bare share count
|
||||
* does not carry:
|
||||
*
|
||||
* <ul>
|
||||
* <li>the board resolution authorising this particular tranche, which is its own meeting and its
|
||||
* own number, not the one the account was opened under;</li>
|
||||
* <li>where the money is coming from, because a tranche nobody has paid for cannot be approved.</li>
|
||||
* </ul>
|
||||
*
|
||||
* Both funding routes converge on the Share Sundry holding account, so approval posts the same
|
||||
* Dr Sundry / Cr Share Capital pair either way:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Cash</b> — the member already paid at the counter; the cashier's receipts put the money
|
||||
* into Sundry at that point. Submitting only reserves those receipts against this tranche.
|
||||
* More than one can be ticked, because a member often pays in instalments and no single
|
||||
* receipt covers the tranche. They are checked here rather than at approval, while the member
|
||||
* is still at the counter to make up any shortfall.</li>
|
||||
* <li><b>Savings</b> — the member's savings are debited into Sundry on submit, so their balance
|
||||
* drops immediately. Approval then moves it out to share capital.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-apply-shares',
|
||||
templateUrl: './apply-shares.component.html',
|
||||
styleUrls: ['./apply-shares.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatRadioModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatIconModule
|
||||
]
|
||||
})
|
||||
export class ApplySharesComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private sharesService = inject(SharesService);
|
||||
private cashierService = inject(CashierService);
|
||||
private dateUtils = inject(Dates);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
@@ -48,14 +81,33 @@ export class ApplySharesComponent implements OnInit {
|
||||
/** Shares Account Id */
|
||||
accountId: any;
|
||||
|
||||
/** Submission in flight, so the form cannot be posted twice. */
|
||||
submitting = false;
|
||||
/** Anything that stopped the submission, shown above the buttons. */
|
||||
errorMsg = '';
|
||||
|
||||
/**
|
||||
* @param {FormBuilder} formBuilder Form Builder
|
||||
* @param {SharesService} sharesService Shares Service
|
||||
* @param {Dates} dateUtils Date Utils
|
||||
* @param {ActivatedRoute} route Activated Route
|
||||
* @param {Router} router Router
|
||||
* @param {SettingsService} settingsService Settings Service
|
||||
* The member's unapplied Share Sundry receipts, and which of them fund this tranche.
|
||||
*
|
||||
* Several, because a member often pays one tranche in instalments — ₹600 today, the ₹400 balance
|
||||
* tomorrow — and neither receipt covers it alone. They are submitted together as one settlement
|
||||
* rather than sending the member back to the counter for a single combined receipt.
|
||||
*/
|
||||
vouchers: any[] = [];
|
||||
selectedVoucherIds: number[] = [];
|
||||
loadingVouchers = false;
|
||||
lookingUpVoucher = false;
|
||||
voucherError = '';
|
||||
|
||||
/** What the member's savings can cover, for the savings route. */
|
||||
savingsEligibility: {
|
||||
eligible: boolean;
|
||||
reason: string | null;
|
||||
savingsAccountNo: string | null;
|
||||
savingsBalance: number | null;
|
||||
} | null = null;
|
||||
checkingSavings = false;
|
||||
|
||||
constructor() {
|
||||
this.accountId = this.route.parent.snapshot.params['shareAccountId'];
|
||||
this.route.data.subscribe((data: { shareAccountActionData: any }) => {
|
||||
@@ -63,54 +115,311 @@ export class ApplySharesComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the apply shares form.
|
||||
* Fetching data from service as action buttons malfunction
|
||||
* in clients view upon using a common resolver.
|
||||
*/
|
||||
ngOnInit() {
|
||||
this.maxDate = this.settingsService.businessDate;
|
||||
this.createApplySharesAccountForm();
|
||||
this.applySharesForm.get('unitPrice').patchValue(this.sharesAccountData.currentMarketPrice || '');
|
||||
this.applySharesForm.get('amount').patchValue(this.amount || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the apply shares form.
|
||||
*
|
||||
* The request date opens on the business date rather than blank: an additional-share request is
|
||||
* recorded the day it is taken, and a clerk who has to fill that in by hand is being asked to
|
||||
* retype what the system already knows.
|
||||
*/
|
||||
createApplySharesAccountForm() {
|
||||
this.applySharesForm = this.formBuilder.group({
|
||||
requestedDate: [
|
||||
'',
|
||||
this.settingsService.businessDate,
|
||||
Validators.required
|
||||
],
|
||||
requestedShares: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
unitPrice: [{ value: '', disabled: true }]
|
||||
unitPrice: [{ value: '', disabled: true }],
|
||||
// Disabled, not readonly: the amount is derived from shares x price and must never be a
|
||||
// figure somebody typed, or the tranche's value and its GL posting could disagree.
|
||||
amount: [{ value: '', disabled: true }],
|
||||
resolutionNumber: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
resolutionDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
paymentMode: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
|
||||
// Recompute as the share count is typed, so the clerk can read the amount back to the member
|
||||
// before committing to a funding route.
|
||||
this.applySharesForm.get('requestedShares').valueChanges.subscribe(() => {
|
||||
this.applySharesForm.get('amount').patchValue(this.amount || '');
|
||||
// A savings verdict is cleared the moment the amount changes rather than when the re-check
|
||||
// returns, so a stale "can cover it" is never on screen beside a larger amount. The ticked
|
||||
// vouchers stay: they are the receipts the member actually holds, and the running total below
|
||||
// shows against the new amount whether they still cover it.
|
||||
this.voucherError = '';
|
||||
this.savingsEligibility = null;
|
||||
});
|
||||
|
||||
// Debounced separately from the clearing above: the check is a server round-trip, and firing it
|
||||
// on every keystroke of "1000" asks four times and lets the answers land out of order.
|
||||
this.applySharesForm
|
||||
.get('requestedShares')
|
||||
.valueChanges.pipe(debounceTime(400), distinctUntilChanged())
|
||||
.subscribe(() => {
|
||||
if (this.paymentMode === 'SAVINGS') {
|
||||
this.checkSavings();
|
||||
}
|
||||
});
|
||||
|
||||
this.applySharesForm.get('paymentMode').valueChanges.subscribe((mode: string) => {
|
||||
this.vouchers = [];
|
||||
this.selectedVoucherIds = [];
|
||||
this.voucherError = '';
|
||||
this.errorMsg = '';
|
||||
this.savingsEligibility = null;
|
||||
if (mode === 'SAVINGS') {
|
||||
this.checkSavings();
|
||||
} else if (mode === 'CASH') {
|
||||
this.loadVouchers();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Shares x unit price — what this tranche is worth, and what has to be funded. */
|
||||
get amount(): number {
|
||||
const shares = Number(this.applySharesForm?.get('requestedShares')?.value) || 0;
|
||||
const price = Number(this.applySharesForm?.get('unitPrice')?.value) || 0;
|
||||
return shares * price;
|
||||
}
|
||||
|
||||
get paymentMode(): string {
|
||||
return this.applySharesForm?.get('paymentMode')?.value || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the form and applies additional shares to the share account,
|
||||
* if successful redirects to the share account.
|
||||
* Loads the member's unapplied Share Sundry receipts so they can be ticked off.
|
||||
*
|
||||
* A single receipt is ticked for them: with only one candidate there is nothing to choose, and
|
||||
* making the clerk tick it adds a step that can only be got wrong by forgetting it.
|
||||
*/
|
||||
private loadVouchers(): void {
|
||||
this.loadingVouchers = true;
|
||||
this.cashierService
|
||||
.getUnappliedShareAdvances(this.sharesAccountData.clientId)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.voucherError = this.readError(err, 'Could not load the member’s receipts.');
|
||||
this.loadingVouchers = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.loadingVouchers = false;
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
this.vouchers = res.unappliedCollections || [];
|
||||
if (this.vouchers.length === 1) {
|
||||
this.selectedVoucherIds = [this.vouchers[0].id];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isVoucherSelected(id: number): boolean {
|
||||
return this.selectedVoucherIds.includes(id);
|
||||
}
|
||||
|
||||
toggleVoucher(id: number): void {
|
||||
this.selectedVoucherIds = this.isVoucherSelected(id) ? this.selectedVoucherIds.filter((x) => x !== id) : [
|
||||
...this.selectedVoucherIds,
|
||||
id
|
||||
];
|
||||
this.voucherError = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* What the ticked receipts come to.
|
||||
*
|
||||
* Shown live, because the submission only goes through when the total covers the tranche, and
|
||||
* adding the rows up by eye is exactly what leads to a rejected submission.
|
||||
*/
|
||||
get selectedVoucherTotal(): number {
|
||||
return this.vouchers.filter((v) => this.isVoucherSelected(v.id)).reduce((sum, v) => sum + Number(v.amount || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a voucher number the member quotes and adds it to the list.
|
||||
*
|
||||
* Kept alongside the tick list rather than replacing it: the member usually quotes the number on
|
||||
* the slip in their hand, which is faster than finding it among their receipts. The lookup is
|
||||
* deliberately not restricted to this member, so a voucher belonging to somebody else is
|
||||
* surfaced here as a plain mismatch rather than silently ignored.
|
||||
*/
|
||||
lookupVoucher(voucherNo: string): void {
|
||||
const trimmed = (voucherNo || '').trim();
|
||||
if (!trimmed || this.lookingUpVoucher) {
|
||||
return;
|
||||
}
|
||||
this.lookingUpVoucher = true;
|
||||
this.voucherError = '';
|
||||
|
||||
this.cashierService
|
||||
.getShareAdvanceByVoucher(trimmed, this.sharesAccountData.clientId)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`);
|
||||
this.lookingUpVoucher = false;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.lookingUpVoucher = false;
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
if (Number(res.client_id) !== Number(this.sharesAccountData.clientId)) {
|
||||
this.voucherError = `Voucher ${trimmed} belongs to ${res.client_name}, not the holder of this share account.`;
|
||||
return;
|
||||
}
|
||||
// Added to the selection rather than replacing it — a looked-up voucher is usually the
|
||||
// second half of a part-payment, not a change of mind about the first. No amount check
|
||||
// here: one instalment is not meant to cover the tranche on its own.
|
||||
this.vouchers = [
|
||||
res,
|
||||
...this.vouchers.filter((v) => v.id !== res.id)
|
||||
];
|
||||
if (!this.isVoucherSelected(res.id)) {
|
||||
this.selectedVoucherIds = [
|
||||
...this.selectedVoucherIds,
|
||||
res.id
|
||||
];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the member's savings can cover this tranche.
|
||||
*
|
||||
* Asks the tranche-scoped endpoint rather than judging it here: the server prices the shares from
|
||||
* the product's market price, so the figure the balance is checked against is the same one the
|
||||
* submission will be valued at. Deciding it in the browser would let the two drift apart.
|
||||
*/
|
||||
private checkSavings(): void {
|
||||
const shares = Number(this.applySharesForm.get('requestedShares').value) || 0;
|
||||
this.checkingSavings = true;
|
||||
this.sharesService
|
||||
.getAdditionalSharesSavingsEligibility(this.accountId, shares)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.checkingSavings = false;
|
||||
if (!res) {
|
||||
this.savingsEligibility = {
|
||||
eligible: false,
|
||||
reason: 'Could not check the savings account linked to this share account.',
|
||||
savingsAccountNo: null,
|
||||
savingsBalance: null
|
||||
};
|
||||
return;
|
||||
}
|
||||
this.savingsEligibility = {
|
||||
eligible: !!res.eligible,
|
||||
reason: res.reason ?? null,
|
||||
savingsAccountNo: res.savingsAccountNo ?? null,
|
||||
savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the chosen funding route is ready — receipts that between them cover the tranche, or
|
||||
* savings that can cover it.
|
||||
*
|
||||
* The total is checked here rather than only on submit, so a part-paid tranche is visibly short
|
||||
* at the counter instead of being refused after the member has left.
|
||||
*/
|
||||
get fundingReady(): boolean {
|
||||
if (this.paymentMode === 'CASH') {
|
||||
return this.selectedVoucherIds.length > 0 && this.selectedVoucherTotal >= this.amount;
|
||||
}
|
||||
if (this.paymentMode === 'SAVINGS') {
|
||||
return !!this.savingsEligibility?.eligible;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The shortfall to tell the member about, or zero when the ticked receipts cover the tranche. */
|
||||
get voucherShortfall(): number {
|
||||
const short = this.amount - this.selectedVoucherTotal;
|
||||
return short > 0 ? short : 0;
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return this.applySharesForm.valid && this.amount > 0 && this.fundingReady && !this.submitting;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the request. Nothing is issued here — the tranche sits pending until it is approved,
|
||||
* which is what moves the money out of Sundry into share capital.
|
||||
*/
|
||||
submit() {
|
||||
const applySharesFormData = this.applySharesForm.value;
|
||||
if (!this.canSubmit) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.errorMsg = '';
|
||||
|
||||
const formValue = this.applySharesForm.value;
|
||||
const locale = this.settingsService.language.code;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const prevRequestedDate: Date = this.applySharesForm.value.requestedDate;
|
||||
if (applySharesFormData.requestedDate instanceof Date) {
|
||||
applySharesFormData.requestedDate = this.dateUtils.formatDate(prevRequestedDate, dateFormat);
|
||||
}
|
||||
const data = {
|
||||
...applySharesFormData,
|
||||
|
||||
const data: any = {
|
||||
requestedShares: formValue.requestedShares,
|
||||
requestedDate: this.dateUtils.formatDate(formValue.requestedDate, dateFormat),
|
||||
unitPrice: this.applySharesForm.get('unitPrice').value,
|
||||
resolutionNumber: formValue.resolutionNumber,
|
||||
meetingDate: this.dateUtils.formatDate(formValue.resolutionDate, dateFormat),
|
||||
fundingSource: formValue.paymentMode,
|
||||
dateFormat,
|
||||
locale
|
||||
};
|
||||
this.sharesService.executeSharesAccountCommand(this.accountId, 'applyadditionalshares', data).subscribe(() => {
|
||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||
if (formValue.paymentMode === 'CASH') {
|
||||
// First receipt leads, the rest ride along; the server settles them as one funding.
|
||||
const [
|
||||
leadId,
|
||||
...additionalCollectionIds
|
||||
] = this.selectedVoucherIds;
|
||||
data.advanceCollectionId = leadId;
|
||||
if (additionalCollectionIds.length > 0) {
|
||||
data.additionalCollectionIds = additionalCollectionIds;
|
||||
}
|
||||
}
|
||||
|
||||
this.sharesService.executeSharesAccountCommand(this.accountId, 'applyadditionalshares', data).subscribe({
|
||||
next: () => {
|
||||
this.router.navigate(['../../'], { relativeTo: this.route });
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
this.errorMsg = this.readError(err, 'The additional-share request could not be submitted.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private readError(err: any, fallback: string): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.developerMessage ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user