/** * 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: * * * * 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); 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(); 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 ); } }