feat: implement share account actions, view components, and pending checker tasks

This commit is contained in:
2026-08-27 10:23:25 +05:30
parent 1273bdbe0e
commit 83e58376cb
32 changed files with 1699 additions and 93 deletions
@@ -70,8 +70,9 @@ export class ActivateSharesAccountComponent implements OnInit {
*/
createActivateSharesAccountForm() {
this.activateSharesAccountForm = this.formBuilder.group({
// Opens on the business date; see the note in ApproveSharesAccountComponent.
activatedDate: [
'',
this.settingsService.businessDate,
Validators.required
]
});
@@ -31,9 +31,42 @@
}
</mat-form-field>
<!-- The board resolution authorising this tranche. Its own meeting and number, not the
one the account was opened under, so it is captured per purchase. -->
<mat-form-field>
<mat-label>{{ 'labels.inputs.Resolution Number' | translate }}</mat-label>
<input matInput required formControlName="resolutionNumber" />
@if (applySharesForm.controls.resolutionNumber.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field (click)="resolutionDatePicker.open()">
<mat-label>{{ 'labels.inputs.Resolution Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="resolutionDatePicker"
required
formControlName="resolutionDate"
/>
<mat-datepicker-toggle matSuffix [for]="resolutionDatePicker"></mat-datepicker-toggle>
<mat-datepicker #resolutionDatePicker></mat-datepicker>
@if (applySharesForm.controls.resolutionDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Total No. of Shares' | translate }}</mat-label>
<input matInput required formControlName="requestedShares" />
<input matInput type="number" min="1" required formControlName="requestedShares" />
@if (applySharesForm.controls.requestedShares.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }}
@@ -44,8 +77,194 @@
<mat-form-field>
<mat-label>{{ 'labels.inputs.Current Price' | translate }}</mat-label>
<input matInput required formControlName="unitPrice" />
<input matInput formControlName="unitPrice" />
</mat-form-field>
<!-- Shares x price, computed rather than entered, so the value posted to the GL is the
value the clerk read back to the member. -->
<mat-form-field>
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
<input matInput formControlName="amount" />
<mat-hint
>{{ 'labels.inputs.Total No. of Shares' | translate }} &times;
{{ 'labels.inputs.Current Price' | translate }}</mat-hint
>
</mat-form-field>
<!-- Where the money comes from. Both routes land in Share Sundry, and approval is what
moves it out to share capital. -->
<div class="payment-mode">
<div class="payment-mode-label">{{ 'labels.inputs.Payment Option' | translate }}</div>
<mat-radio-group formControlName="paymentMode" class="payment-mode-options">
<mat-radio-button value="CASH">Apply Collection (Cash)</mat-radio-button>
<mat-radio-button value="SAVINGS">Debit from Savings</mat-radio-button>
</mat-radio-group>
</div>
@if (paymentMode === 'CASH') {
<div class="funding-panel">
<p class="funding-intro">
The member has already paid at the counter. Tick the receipts that make up this payment, or enter a
voucher number to find one — tick more than one where they paid in instalments. The cash is sitting in
Share Sundry against them, and approving this request moves it into share capital.
</p>
<div class="voucher-lookup">
<input
#voucherInput
type="text"
class="voucher-input"
placeholder="Voucher no. (e.g. 000123)"
[disabled]="lookingUpVoucher || submitting"
(keyup.enter)="lookupVoucher(voucherInput.value)"
/>
<button
mat-stroked-button
type="button"
[disabled]="!voucherInput.value.trim() || lookingUpVoucher || submitting"
(click)="lookupVoucher(voucherInput.value)"
>
{{ lookingUpVoucher ? 'Finding…' : 'Find' }}
</button>
</div>
@if (voucherError) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ voucherError }}</span>
</div>
}
@if (loadingVouchers) {
<div class="funding-loading">
<mat-spinner diameter="24"></mat-spinner>
<span>Loading the member's receipts…</span>
</div>
} @else if (vouchers.length === 0) {
<div class="funding-empty">
<mat-icon>info_outline</mat-icon>
<div>
<b>No unapplied receipt for this member.</b>
<div class="funding-empty-hint">
Collect the cash first from Cashier &rsaquo; Transaction Entry as a deposit to the Share Sundry
GL. Once it is approved, the voucher appears here.
</div>
</div>
</div>
} @else {
<!-- Checkboxes, not radios: a member paying in instalments has several receipts
against one request, and they fund it together. -->
<table class="receipt-table">
<thead>
<tr>
<th></th>
<th class="ta-left">Voucher / Receipt</th>
<th class="ta-left">Date</th>
<th class="ta-right">Amount</th>
</tr>
</thead>
<tbody>
@for (v of vouchers; track v.id) {
<tr [class.selected]="isVoucherSelected(v.id)" (click)="toggleVoucher(v.id)">
<td class="ta-center">
<input
type="checkbox"
[checked]="isVoucherSelected(v.id)"
(change)="toggleVoucher(v.id)"
(click)="$event.stopPropagation()"
/>
</td>
<td class="ta-left mono">{{ v.voucher_no || v.receipt_no }}</td>
<td class="ta-left">{{ v.collection_date }}</td>
<td class="ta-right">
<b>{{ v.amount | number: '1.2-2' }}</b>
</td>
</tr>
}
</tbody>
@if (selectedVoucherIds.length > 0) {
<tfoot>
<tr class="total-row">
<td></td>
<td class="ta-left" colspan="2">
{{ selectedVoucherIds.length }} selected, against {{ amount | number: '1.2-2' }}
</td>
<td class="ta-right">
<b>{{ selectedVoucherTotal | number: '1.2-2' }}</b>
</td>
</tr>
</tfoot>
}
</table>
<!-- Says how much is still missing rather than only that it is short, so the clerk
can tell the member what to pay without working it out. -->
@if (selectedVoucherIds.length > 0 && voucherShortfall > 0) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>
The selected receipts are {{ voucherShortfall | number: '1.2-2' }} short of
{{ amount | number: '1.2-2' }}. Tick another receipt, or collect the balance at the counter first.
</span>
</div>
}
}
</div>
}
@if (paymentMode === 'SAVINGS') {
<div class="funding-panel">
<p class="funding-intro">
The amount is debited from the member's linked savings into Share Sundry when this is submitted, so
their balance drops now. Approving the request then moves it into share capital.
</p>
@if (checkingSavings) {
<div class="funding-loading">
<mat-spinner diameter="24"></mat-spinner>
<span>Checking the linked savings account…</span>
</div>
} @else if (savingsEligibility) {
<div class="funding-summary">
@if (savingsEligibility.savingsAccountNo) {
<div class="funding-row">
<span>Savings account</span>
<span class="mono">{{ savingsEligibility.savingsAccountNo }}</span>
</div>
<div class="funding-row">
<span>Available balance</span>
<span>{{ savingsEligibility.savingsBalance | number: '1.2-2' }}</span>
</div>
}
<div class="funding-row funding-row--total">
<span>To debit</span>
<span>{{ amount | number: '1.2-2' }}</span>
</div>
</div>
@if (!savingsEligibility.eligible) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ savingsEligibility.reason }}</span>
</div>
}
}
</div>
}
@if (errorMsg) {
<div class="alert-error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
<!-- Said plainly, so a clerk does not tell the member their shares are issued while a
manager still has to approve the request. -->
<p class="pending-hint">
Submitting records a request for approval. The shares are issued, and the money moved out of Share Sundry,
only once it is approved — until then it appears under pending, not in the account's transactions.
</p>
</div>
</mat-card-content>
@@ -53,8 +272,8 @@
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!applySharesForm.valid">
{{ 'labels.buttons.Submit' | translate }}
<button mat-raised-button color="primary" [disabled]="!canSubmit">
{{ submitting ? 'Submitting…' : ('labels.buttons.Submit' | translate) }}
</button>
</mat-card-actions>
</form>
@@ -6,6 +6,207 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #1a3a6b;
$border: #d8e0ec;
$muted: #6b7a90;
$danger: #b3261e;
.container {
max-width: 37rem;
}
.payment-mode {
margin: 4px 0 16px;
}
.payment-mode-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: $muted;
margin-bottom: 8px;
}
.payment-mode-options {
display: flex;
flex-direction: column;
gap: 4px;
}
/* The funding route's own detail, boxed off from the share fields above it: which voucher, or
which savings account, is what the clerk checks before submitting. */
.funding-panel {
border: 1px solid $border;
border-radius: 6px;
padding: 14px;
margin-bottom: 16px;
background: #fbfcfe;
}
.funding-intro {
font-size: 13px;
color: $muted;
margin: 0 0 12px;
}
.voucher-lookup {
display: flex;
gap: 8px;
align-items: center;
margin: 0 0 12px;
}
.voucher-input {
flex: 1;
min-width: 0;
padding: 9px 11px;
border: 1px solid $border;
border-radius: 6px;
font-family: 'Roboto Mono', monospace;
font-size: 13px;
letter-spacing: 0.4px;
color: $primary;
&:focus {
outline: none;
border-color: $primary;
}
&:disabled {
background: #f4f7fb;
color: $muted;
}
}
.funding-loading {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
font-size: 13px;
color: $muted;
}
.funding-summary {
border: 1px solid $border;
border-radius: 6px;
background: #fff;
padding: 10px 12px;
font-size: 13px;
}
.funding-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 4px 0;
color: $primary;
> span:first-child {
color: $muted;
}
}
.funding-row--total {
border-top: 1px solid #eceff4;
margin-top: 4px;
padding-top: 8px;
font-weight: 500;
}
.mono {
font-family: 'Courier New', monospace;
}
.funding-empty {
display: flex;
gap: 10px;
align-items: flex-start;
background: #f4f7fb;
border: 1px solid $border;
border-radius: 6px;
padding: 12px;
font-size: 13px;
}
.funding-empty-hint {
color: $muted;
margin-top: 4px;
}
.receipt-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
background: #fff;
border: 1px solid $border;
border-radius: 6px;
th {
background: #eef2f8;
color: $primary;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 8px;
}
td {
padding: 8px;
border-bottom: 1px solid #eceff4;
}
tbody tr {
cursor: pointer;
&:hover {
background: #f4f7fb;
}
&.selected {
background: #e7effb;
}
}
/* The running total against the tranche amount — what decides whether the request is accepted,
and not something to be added up by eye once a second receipt is in play. */
tfoot .total-row {
border-top: 2px solid #cbd5e1;
td {
padding-top: 10px;
color: #1f3d7a;
}
}
}
.ta-left {
text-align: left;
}
.ta-right {
text-align: right;
}
.ta-center {
text-align: center;
}
.alert-error {
display: flex;
align-items: center;
gap: 8px;
background: #fdecea;
color: $danger;
border: 1px solid #f5c6c2;
border-radius: 6px;
padding: 10px 12px;
font-size: 13px;
margin-bottom: 12px;
}
.pending-hint {
font-size: 12px;
color: $muted;
margin: 0;
}
@@ -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
);
}
}
@@ -72,8 +72,11 @@ export class ApproveSharesAccountComponent implements OnInit {
*/
createApproveSharesAccountForm() {
this.approveSharesAccountForm = this.formBuilder.group({
// Opens on the business date rather than blank: an approval is recorded the day it is given,
// so this is what the clerk would type anyway. Still editable, and still capped at the
// business date, for an approval being entered after the fact.
approvedDate: [
'',
this.settingsService.businessDate,
Validators.required
],
note: ['']
@@ -26,6 +26,29 @@
<td mat-cell *matCellDef="let share">{{ share.purchasedPrice }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Amount' | translate }}</th>
<td mat-cell *matCellDef="let share">{{ trancheAmount(share) | number: '1.2-2' }}</td>
</ng-container>
<!-- The board resolution authorising this particular tranche, captured on the apply form. -->
<ng-container matColumnDef="resolution">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Resolution Number' | translate }}</th>
<td mat-cell *matCellDef="let share">
{{ share.resolutionNumber || '—' }}
@if (share.meetingDate) {
<span class="resolution-date">({{ share.meetingDate | dateFormat }})</span>
}
</td>
</ng-container>
<!-- Cash means the money is already in Share Sundry against a voucher; Savings means it was
debited from the member when the request was raised. Either way approval moves it out. -->
<ng-container matColumnDef="fundingSource">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payment Option' | translate }}</th>
<td mat-cell *matCellDef="let share">{{ fundingLabel(share) || '—' }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let share">
@@ -19,4 +19,12 @@ table {
margin: 4px;
line-height: 25px;
}
/* The meeting date rides alongside the resolution number rather than taking a column of its
own — it is only ever read together with the number it belongs to. */
.resolution-date {
color: #6b7a90;
font-size: 12px;
margin-left: 4px;
}
}
@@ -32,7 +32,7 @@ import { ApproveShareDialogComponent } from './approve-share-dialog/approve-shar
/** Custom Serices */
import { SharesService } from 'app/shares/shares.service';
import { SettingsService } from 'app/settings/settings.service';
import { NgClass } from '@angular/common';
import { DecimalPipe, NgClass } from '@angular/common';
import { MatTooltip } from '@angular/material/tooltip';
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
@@ -63,7 +63,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatRow,
MatPaginator,
StatusLookupPipe,
DateFormatPipe
DateFormatPipe,
DecimalPipe
]
})
export class ApproveSharesComponent implements OnInit {
@@ -79,11 +80,22 @@ export class ApproveSharesComponent implements OnInit {
accountId: any;
/** Shares account data. */
sharesData: any[];
/** Columns to be displayed in shares table. */
/**
* Columns to be displayed in shares table.
*
* Pending tranches appear here and nowhere else — the Transactions and Passbook tabs list only
* settled movements — so this table has to carry everything the approval turns on: what the
* tranche is worth, the board resolution authorising it, and how it was funded. Approving posts
* Dr Share Sundry / Cr Share Capital, and an approver who cannot see the amount or the funding
* route is being asked to authorise a posting they cannot check.
*/
displayedColumns: string[] = [
'transactionDate',
'totalShares',
'redeemedPrice',
'amount',
'resolution',
'fundingSource',
'status',
'approve'
];
@@ -120,6 +132,51 @@ export class ApproveSharesComponent implements OnInit {
this.setShares();
}
/**
* What the tranche is worth. Taken from the transaction's own `amount` where the server sends
* one, falling back to shares x price — the same product the apply form showed the member.
*/
trancheAmount(share: any): number {
const amount = Number(share?.amount);
if (!Number.isNaN(amount) && amount > 0) {
return amount;
}
return (Number(share?.numberOfShares) || 0) * (Number(share?.purchasedPrice) || 0);
}
/**
* How this tranche was funded, in the words the approver chose on the apply form.
*
* Blank for tranches raised before funding was captured, which is honest: those genuinely have
* no recorded route, and inventing one here would misrepresent them.
*/
fundingLabel(share: any): string {
switch (share?.fundingSource) {
case 'CASH': {
// Says how many receipts when a member paid in instalments, so the approver knows to expect
// several vouchers behind one tranche rather than suspecting a duplicate.
const count = this.voucherCount(share);
return count > 1 ? `Cash (${count} vouchers)` : 'Cash';
}
case 'SAVINGS':
return 'Savings';
default:
return '';
}
}
/** How many receipts fund this tranche — the lead one plus any further instalments. */
private voucherCount(share: any): number {
if (!share?.advanceCollectionId) {
return 0;
}
const extra = (share.additionalCollectionIds || '')
.split(',')
.map((part: string) => part.trim())
.filter((part: string) => part.length > 0);
return 1 + extra.length;
}
/**
* Initializes the data source, paginator and sorter for shares table.
*/
@@ -72,8 +72,9 @@ export class CloseSharesAccountComponent implements OnInit {
*/
createCloseSharesAccountForm() {
this.closeSharesAccountForm = this.formBuilder.group({
// Opens on the business date; see the note in ApproveSharesAccountComponent.
closedDate: [
'',
this.settingsService.businessDate,
Validators.required
],
note: ['']
@@ -79,8 +79,9 @@ export class RedeemSharesComponent implements OnInit {
*/
createRedeemSharesAccountForm() {
this.redeemSharesForm = this.formBuilder.group({
// Opens on the business date; see the note in ApproveSharesAccountComponent.
requestedDate: [
'',
this.settingsService.businessDate,
Validators.required
],
requestedShares: [
@@ -72,8 +72,9 @@ export class RejectSharesAccountComponent implements OnInit {
*/
createRejectSharesAccountForm() {
this.rejectSharesAccountForm = this.formBuilder.group({
// Opens on the business date; see the note in ApproveSharesAccountComponent.
rejectedDate: [
'',
this.settingsService.businessDate,
Validators.required
],
note: ['']