484 lines
18 KiB
TypeScript
484 lines
18 KiB
TypeScript
/**
|
|
* 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 { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
import { Subject, of } from 'rxjs';
|
|
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
|
|
|
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 { ReceiptOption, receiptFromOwn, receiptFromUnattached, receiptGlLabel } from 'app/shares/share-receipt-option';
|
|
|
|
/** Where the money for this tranche comes from. */
|
|
export type FundingSource = 'CASH' | 'SAVINGS';
|
|
|
|
/**
|
|
* Apply Additional Shares.
|
|
*
|
|
* A member buying a further tranche, 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: the board resolution authorising this particular tranche (its own meeting, its own number,
|
|
* not the one the account was opened under), and where the money is coming from, because a tranche
|
|
* nobody has paid for cannot be approved.
|
|
*
|
|
* 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 and the cashier's receipt put the money
|
|
* into Sundry then. Submitting only reserves that receipt against this tranche. One receipt
|
|
* funds one tranche; it is 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>
|
|
*
|
|
* Deliberately the same screen as the first application's Apply Collection dialog — same numbered
|
|
* steps, same receipt list, same voucher search — because it settles the same receipts for the same
|
|
* person, and two different designs for that were only ever two things to learn.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-apply-shares',
|
|
templateUrl: './apply-shares.component.html',
|
|
styleUrls: ['./apply-shares.component.scss'],
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule,
|
|
MatDialogModule,
|
|
MatButtonModule,
|
|
MatIconModule,
|
|
MatProgressSpinnerModule
|
|
]
|
|
})
|
|
export class ApplySharesComponent implements OnInit {
|
|
private sharesService = inject(SharesService);
|
|
private cashierService = inject(CashierService);
|
|
private settingsService = inject(SettingsService);
|
|
private dateUtils = inject(Dates);
|
|
|
|
public dialogRef = inject(MatDialogRef<ApplySharesComponent>);
|
|
public data = inject<{
|
|
shareAccountId: number;
|
|
clientId: number;
|
|
accountNo: string;
|
|
unitPrice: number;
|
|
}>(MAT_DIALOG_DATA);
|
|
|
|
submitting = false;
|
|
errorMsg = '';
|
|
|
|
// ── The tranche ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** How many shares this tranche is for. */
|
|
requestedShares: number | null = null;
|
|
|
|
/** The product's current market price. Read-only: the tranche's value and its GL posting must
|
|
* never be able to disagree because somebody typed over the price. */
|
|
unitPrice = 0;
|
|
|
|
/** Both as yyyy-MM-dd, the value a native date input reads and writes. */
|
|
requestedDate = '';
|
|
resolutionDate = '';
|
|
resolutionNumber = '';
|
|
|
|
/** Shares x unit price — what this tranche is worth, and what has to be funded. */
|
|
get amount(): number {
|
|
return (Number(this.requestedShares) || 0) * this.unitPrice;
|
|
}
|
|
|
|
// ── Funding ─────────────────────────────────────────────────────────────────────────────────
|
|
|
|
funding: FundingSource | null = null;
|
|
|
|
/** Every Share Collection receipt that can fund this tranche, from both server lists, in one pool. */
|
|
available: ReceiptOption[] = [];
|
|
loadingReceipts = false;
|
|
|
|
/** The one receipt funding this tranche. One receipt, one tranche. */
|
|
receipt: ReceiptOption | null = null;
|
|
|
|
/** Key of the receipt currently being attached to this member, so only its own row shows a spinner. */
|
|
attaching: string | null = null;
|
|
|
|
/** What the member's savings can cover, for the savings route. */
|
|
savings: {
|
|
eligible: boolean;
|
|
reason: string | null;
|
|
savingsAccountNo: string | null;
|
|
savingsBalance: number | null;
|
|
} | null = null;
|
|
checkingSavings = false;
|
|
|
|
/** Re-checks the savings verdict as the share count settles, rather than on every keystroke of
|
|
* "1000" — which asks four times and lets the answers land out of order. */
|
|
private sharesChanged = new Subject<number>();
|
|
|
|
ngOnInit(): void {
|
|
this.unitPrice = Number(this.data.unitPrice) || 0;
|
|
// Opens on the business date rather than blank: a tranche is recorded the day it is taken, and
|
|
// asking a clerk to type that in is asking them to retype what the system already knows.
|
|
this.requestedDate = this.isoOf(this.settingsService.businessDate);
|
|
|
|
this.sharesChanged.pipe(debounceTime(400), distinctUntilChanged()).subscribe(() => {
|
|
if (this.funding === 'SAVINGS') {
|
|
this.checkSavings();
|
|
}
|
|
});
|
|
|
|
// Needed only by the voucher search, which is the exception path -- loaded quietly in the
|
|
// background so opening that popup never waits on a request.
|
|
this.cashierService
|
|
.getShareAdvanceGlAccounts()
|
|
.pipe(catchError(() => of(null)))
|
|
.subscribe((res: any) => {
|
|
this.glAccounts = res?.glAccounts || [];
|
|
this.loadingGlAccounts = false;
|
|
});
|
|
}
|
|
|
|
onSharesChange(): void {
|
|
// The 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 chosen receipt stays --
|
|
// it is the receipt the member actually holds, and the shortfall line below re-reads against the
|
|
// new amount on its own.
|
|
this.savings = null;
|
|
this.errorMsg = '';
|
|
this.sharesChanged.next(Number(this.requestedShares) || 0);
|
|
}
|
|
|
|
chooseFunding(source: FundingSource): void {
|
|
if (this.funding === source) {
|
|
return;
|
|
}
|
|
this.funding = source;
|
|
this.receipt = null;
|
|
this.savings = null;
|
|
this.errorMsg = '';
|
|
if (source === 'SAVINGS') {
|
|
this.checkSavings();
|
|
} else if (!this.available.length) {
|
|
this.loadReceipts();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads every Share Collection receipt that could fund this tranche — the member's own, and the
|
|
* ones still belonging to nobody — as one list.
|
|
*
|
|
* Nothing is pre-selected, not even when a single receipt is listed: which receipt funds the
|
|
* tranche is a choice the person applying it makes, not one they inherit from a pre-ticked row
|
|
* they never looked at.
|
|
*/
|
|
private loadReceipts(): void {
|
|
this.loadingReceipts = true;
|
|
this.cashierService
|
|
.getShareCollectionReceipts(this.data.clientId)
|
|
.pipe(
|
|
catchError((err: any) => {
|
|
this.errorMsg = this.readError(err, 'Could not load the available receipts.');
|
|
this.loadingReceipts = false;
|
|
return of(null);
|
|
})
|
|
)
|
|
.subscribe((res: any) => {
|
|
this.loadingReceipts = false;
|
|
if (!res) {
|
|
return;
|
|
}
|
|
this.available = [
|
|
...(res.own || []).map((row: any) => receiptFromOwn(row)),
|
|
...(res.rows || []).map((row: any) => receiptFromUnattached(row))
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Chooses the receipt funding this tranche, attaching it to this member first if it is still only
|
|
* a voucher number.
|
|
*
|
|
* A receipt paid straight into a GL account names no member — a GL account is nobody's account —
|
|
* so it has to be attributed before it can fund anything. That attribution is exactly the
|
|
* by-voucher lookup a hand-typed number already runs; doing it on the pick makes it one deliberate
|
|
* choice against a member on screen, rather than an Attach in one table and a tick in another.
|
|
*/
|
|
pick(option: ReceiptOption): void {
|
|
if (this.attaching || this.submitting) {
|
|
return;
|
|
}
|
|
if (this.receipt?.key === option.key) {
|
|
this.receipt = null;
|
|
return;
|
|
}
|
|
if (option.collectionId !== null) {
|
|
this.receipt = option;
|
|
return;
|
|
}
|
|
|
|
this.attaching = option.key;
|
|
this.errorMsg = '';
|
|
this.findVoucher(option.voucherNo, option.glAccountNo).subscribe((res) => {
|
|
this.attaching = null;
|
|
if (!res) {
|
|
return;
|
|
}
|
|
// Attached now, so it takes its real row's place in the pool -- picking it again must never
|
|
// run the attach a second time.
|
|
const attached = receiptFromOwn(res);
|
|
this.available = this.available.map((entry) => (entry.key === option.key ? attached : entry));
|
|
this.receipt = attached;
|
|
});
|
|
}
|
|
|
|
clearReceipt(): void {
|
|
this.receipt = null;
|
|
}
|
|
|
|
/** What the chosen receipt is still missing, zero once it covers the tranche. */
|
|
get shortfall(): number {
|
|
const short = this.amount - Number(this.receipt?.amount || 0);
|
|
return this.receipt && short > 0 ? short : 0;
|
|
}
|
|
|
|
/**
|
|
* 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.requestedShares) || 0;
|
|
this.checkingSavings = true;
|
|
this.sharesService
|
|
.getAdditionalSharesSavingsEligibility(String(this.data.shareAccountId), shares)
|
|
.pipe(catchError(() => of(null)))
|
|
.subscribe((res: any) => {
|
|
this.checkingSavings = false;
|
|
this.savings = res
|
|
? {
|
|
eligible: !!res.eligible,
|
|
reason: res.reason ?? null,
|
|
savingsAccountNo: res.savingsAccountNo ?? null,
|
|
savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance)
|
|
}
|
|
: {
|
|
eligible: false,
|
|
reason: 'Could not check the savings account linked to this share account.',
|
|
savingsAccountNo: null,
|
|
savingsBalance: null
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Whether the chosen funding route is ready — a receipt that covers the tranche, or savings that
|
|
* can.
|
|
*
|
|
* 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.funding === 'CASH') {
|
|
return !!this.receipt && this.shortfall === 0;
|
|
}
|
|
if (this.funding === 'SAVINGS') {
|
|
return !!this.savings?.eligible;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
get canSubmit(): boolean {
|
|
return (
|
|
this.amount > 0 &&
|
|
!!this.requestedDate &&
|
|
!!this.resolutionNumber.trim() &&
|
|
!!this.resolutionDate &&
|
|
this.fundingReady &&
|
|
!this.submitting
|
|
);
|
|
}
|
|
|
|
// ── Voucher search, for a receipt the list does not carry ───────────────────────────────────
|
|
|
|
searchOpen = false;
|
|
searchVoucherNo = '';
|
|
searchGlAccountNo = '';
|
|
searching = false;
|
|
|
|
/** GL accounts, for the search only — a voucher is found by number *and* the account it sits in. */
|
|
glAccounts: any[] = [];
|
|
loadingGlAccounts = true;
|
|
|
|
openSearch(): void {
|
|
this.searchOpen = true;
|
|
this.searchVoucherNo = '';
|
|
this.searchGlAccountNo = this.glAccounts.length === 1 ? this.glAccounts[0].gl_code : '';
|
|
this.errorMsg = '';
|
|
}
|
|
|
|
closeSearch(): void {
|
|
this.searchOpen = false;
|
|
this.errorMsg = '';
|
|
}
|
|
|
|
runSearch(): void {
|
|
const voucher = this.searchVoucherNo.trim();
|
|
const glAccountNo = this.searchGlAccountNo.trim();
|
|
// Both fields are genuinely needed: a blank GL account here means nothing has been chosen yet,
|
|
// not that none is required.
|
|
if (!voucher || !glAccountNo || this.searching) {
|
|
return;
|
|
}
|
|
this.searching = true;
|
|
this.errorMsg = '';
|
|
this.findVoucher(voucher, glAccountNo).subscribe((res) => {
|
|
this.searching = false;
|
|
if (!res) {
|
|
return;
|
|
}
|
|
const found = receiptFromOwn(res);
|
|
this.available = [
|
|
found,
|
|
...this.available.filter((entry) => entry.key !== found.key && entry.voucherNo !== found.voucherNo)
|
|
];
|
|
this.receipt = found;
|
|
this.searchOpen = false;
|
|
});
|
|
}
|
|
|
|
private findVoucher(voucher: string, glAccountNo: string) {
|
|
// No client-match check: any receipt can fund any share account, regardless of which member it
|
|
// was first recorded against.
|
|
return this.cashierService.getShareAdvanceByVoucher(voucher, glAccountNo, this.data.clientId).pipe(
|
|
catchError((err: any) => {
|
|
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found in that GL account.`);
|
|
return of(null);
|
|
})
|
|
);
|
|
}
|
|
|
|
// ── A receipt's own GL entries ──────────────────────────────────────────────────────────────
|
|
|
|
viewing: ReceiptOption | null = null;
|
|
|
|
/** The receipt's own real Dr/Cr legs, read under its own transaction id — never the GL account
|
|
* picked in the search box, which is unrelated screen state naming a different account. */
|
|
viewingGlEntries: any[] | null = null;
|
|
loadingGlEntries = false;
|
|
|
|
openDetail(option: ReceiptOption, event: Event): void {
|
|
event.stopPropagation();
|
|
if (option.collectionId === null) {
|
|
return;
|
|
}
|
|
this.viewing = option;
|
|
this.viewingGlEntries = null;
|
|
this.loadingGlEntries = true;
|
|
this.cashierService
|
|
.getShareAdvanceGlEntries(option.collectionId)
|
|
.pipe(catchError(() => of(null)))
|
|
.subscribe((res: any) => {
|
|
this.viewingGlEntries = res?.entries || [];
|
|
this.loadingGlEntries = false;
|
|
});
|
|
}
|
|
|
|
closeDetail(): void {
|
|
this.viewing = null;
|
|
this.viewingGlEntries = null;
|
|
}
|
|
|
|
// ── Submission ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 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(): void {
|
|
if (!this.canSubmit) {
|
|
return;
|
|
}
|
|
this.submitting = true;
|
|
this.errorMsg = '';
|
|
|
|
const dateFormat = this.settingsService.dateFormat;
|
|
const payload: any = {
|
|
requestedShares: this.requestedShares,
|
|
requestedDate: this.dateUtils.formatDate(this.dateOf(this.requestedDate), dateFormat),
|
|
unitPrice: this.unitPrice,
|
|
resolutionNumber: this.resolutionNumber.trim(),
|
|
meetingDate: this.dateUtils.formatDate(this.dateOf(this.resolutionDate), dateFormat),
|
|
fundingSource: this.funding,
|
|
dateFormat,
|
|
locale: this.settingsService.language.code
|
|
};
|
|
if (this.funding === 'CASH' && this.receipt) {
|
|
payload.advanceCollectionId = this.receipt.collectionId;
|
|
}
|
|
|
|
this.sharesService
|
|
.executeSharesAccountCommand(String(this.data.shareAccountId), 'applyadditionalshares', payload)
|
|
.subscribe({
|
|
next: (res: any) => {
|
|
this.dialogRef.close(res || true);
|
|
},
|
|
error: (err: any) => {
|
|
this.submitting = false;
|
|
this.errorMsg = this.readError(err, 'The additional-share request could not be submitted.');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** The GL account a receipt sits in, named from the list the voucher search already loaded. */
|
|
glLabel(option: ReceiptOption): string {
|
|
return receiptGlLabel(option, this.glAccounts);
|
|
}
|
|
|
|
/** A Date as the yyyy-MM-dd a native date input reads, in local time. */
|
|
private isoOf(date: Date): string {
|
|
const local = new Date(date);
|
|
return `${local.getFullYear()}-${String(local.getMonth() + 1).padStart(2, '0')}-${String(local.getDate()).padStart(2, '0')}`;
|
|
}
|
|
|
|
/**
|
|
* A yyyy-MM-dd back into a local Date.
|
|
*
|
|
* Built from the parts rather than {@code new Date(iso)}, which reads the string as UTC midnight
|
|
* and so lands on the previous day for anyone west of Greenwich — a request dated a day early is
|
|
* not something a date picker should be able to produce.
|
|
*/
|
|
private dateOf(iso: string): Date {
|
|
const [
|
|
year,
|
|
month,
|
|
day
|
|
] = iso.split('-').map(Number);
|
|
return new Date(year, month - 1, day);
|
|
}
|
|
|
|
private readError(err: any, fallback: string): string {
|
|
return (
|
|
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
err?.error?.defaultUserMessage ||
|
|
err?.error?.developerMessage ||
|
|
fallback
|
|
);
|
|
}
|
|
}
|