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
+34
View File
@@ -0,0 +1,34 @@
/**
* 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/.
*/
/**
* Which of an account's `purchasedShares` rows belong in a record of money that has moved.
*
* `purchasedShares` is not a transaction list — it is every row in `m_share_account_transactions`,
* which is also where a request *awaiting* approval is parked. A first application and an
* additional-share request are stored identically: same table, same APPLIED status, differing only
* in when they were raised. So a single predicate covers both, and neither needs special-casing.
*
* Excluded:
*
* - **Charge payments** — a fee, not a share movement; the Charges tab is where those belong.
* - **Applied (pending approval)** — nobody has approved it and, on the additional-share route, the
* money is still sitting in Share Sundry. Listing it as a transaction reads as though the member
* has paid and holds the shares when they hold neither. Pending requests appear under Approve
* Additional Shares, which is the view that exists to act on them.
* - **Rejected** — a request that was refused never became a movement.
*/
export function isSettledShareTransaction(transaction: any): boolean {
const type = transaction?.type?.value;
const statusCode = transaction?.status?.code;
if (type === 'Charge Payment') {
return false;
}
return statusCode !== 'purchasedSharesStatusType.applied' && statusCode !== 'purchasedSharesStatusType.rejected';
}
@@ -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: ['']
@@ -96,8 +96,11 @@ export class SharesAccountDetailsStepComponent implements OnInit {
'',
Validators.required
],
// Opens on the business date rather than blank: an application 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. Still editable, for an application being entered after the fact.
submittedDate: [
'',
this.settingsService.businessDate,
Validators.required
],
externalId: [''],
@@ -10,17 +10,18 @@
<mat-dialog-content>
<p class="intro">
Enter the voucher number the member was given, or tick the receipts below, to settle against share account
<b>{{ data.accountNo }}</b
>. Tick more than one where the member paid in instalments. The amount is moved out of Share Sundry into share
capital, and any entry fee is posted to fee income.
Tick the receipts that make up this payment to settle share account <b>{{ data.accountNo }}</b
>, then apply. Today's unapplied receipts for this member are listed below — tick more than one where they paid in
instalments. The amount moves out of Share Sundry into share capital, and any entry fee is posted to fee income.
</p>
<!-- For a receipt the list does not carry — one taken on an earlier day, or a slip the member is
holding that is quicker to type than to find. -->
<div class="voucher-lookup">
<input
type="text"
class="voucher-input"
placeholder="Voucher no. (e.g. 000123)"
placeholder="Voucher no. (e.g. CR/001/2608/0001)"
[(ngModel)]="voucherNo"
[disabled]="lookingUp || submitting"
(keyup.enter)="lookupVoucher()"
@@ -51,10 +52,11 @@
<div class="empty">
<mat-icon>info_outline</mat-icon>
<div>
<b>No unapplied receipt for this member.</b>
<b>No unapplied receipt for this member today.</b>
<div class="empty-hint">
Collect the cash first from Cashier &rsaquo; Transaction Entry as a deposit to the Share Sundry GL. Once a
manager approves it, enter the voucher number above.
If the cash was taken on an earlier day, enter that voucher number above. Otherwise collect it first from
Cashier &rsaquo; Transaction Entry as a deposit to the Share Sundry GL — once a manager approves it, the
voucher appears here.
</div>
</div>
</div>
@@ -62,7 +64,7 @@
<table class="receipt-table">
<thead>
<tr>
<th></th>
<th class="col-tick"></th>
<th class="ta-left">Voucher / Receipt</th>
<th class="ta-left">Date</th>
<th class="ta-right">Amount</th>
@@ -71,7 +73,7 @@
<tbody>
@for (r of receipts; track r.id) {
<tr [class.selected]="isSelected(r.id)" (click)="toggle(r.id)">
<td class="ta-center">
<td class="ta-center col-tick">
<!-- Checkboxes, not radios: a member paying in instalments has several receipts
against one application, and all of them settle it together. -->
<input
@@ -83,28 +85,29 @@
</td>
<td class="ta-left mono">{{ r.voucher_no || r.receipt_no }}</td>
<td class="ta-left">{{ r.collection_date }}</td>
<td class="ta-right">
<b>{{ r.amount }}</b>
</td>
<td class="ta-right amount-cell">{{ r.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
@if (selectedIds.length > 1) {
<!-- Shown from the first tick rather than the second: the total against what the account needs
is what decides whether the settlement is accepted, so it should be on screen as soon as
there is anything to total. -->
@if (selectedIds.length > 0) {
<tfoot>
<tr class="total-row">
<td></td>
<td class="ta-left" colspan="2">{{ selectedIds.length }} receipts selected</td>
<td class="ta-right">
<b>{{ selectedTotal | number: '1.2-2' }}</b>
<td class="col-tick"></td>
<td class="ta-left" colspan="2">
{{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected
</td>
<td class="ta-right amount-cell">{{ selectedTotal | number: '1.2-2' }}</td>
</tr>
</tfoot>
}
</table>
<div class="note">
Select every receipt that makes up this payment. Their total must cover the account's share value plus any entry
fee — anything paid over that is credited to the member's savings account.
Their total must cover the account's share value plus any entry fee — anything paid over that is credited to the
member's savings account.
</div>
}
</mat-dialog-content>
@@ -75,19 +75,35 @@ $danger: #b3261e;
width: 100%;
border-collapse: collapse;
font-size: 13px;
border: 1px solid $border;
border-radius: 6px;
overflow: hidden;
th {
background: #eef2f8;
color: $primary;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 8px;
padding: 9px 12px;
white-space: nowrap;
}
td {
padding: 8px;
padding: 10px 12px;
border-bottom: 1px solid #eceff4;
color: #1f2937;
}
tbody tr:last-child td {
border-bottom: none;
}
/* Just wide enough for the box, so the voucher number starts near the left edge instead of
after a column of empty space. */
.col-tick {
width: 36px;
}
tbody tr {
@@ -102,15 +118,24 @@ $danger: #b3261e;
}
}
/* Only appears once a second receipt is ticked, when adding the rows up by eye stops being
reliable — and the total is what decides whether the settlement is accepted. */
/* Tabular figures so the amounts line up digit for digit down the column and against the total
below them — the whole point of the column is that it can be read as a sum. */
.amount-cell {
font-variant-numeric: tabular-nums;
font-weight: 500;
white-space: nowrap;
}
tfoot .total-row {
background: #f4f7fb;
border-top: 2px solid #cbd5e1;
td {
padding-top: 10px;
font-size: 13px;
font-weight: 600;
color: #1f3d7a;
border-bottom: none;
}
}
}
@@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, Inject, inject } from '@angular/core';
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';
@@ -16,6 +16,8 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { CashierService } from 'app/cashier/cashier.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/**
* Applies a Share Advance receipt to an activated share account.
@@ -23,6 +25,14 @@ import { CashierService } from 'app/cashier/cashier.service';
* The member's cash is already sitting in the Share Sundry liability from the cashier's advance
* collection. Applying a receipt here posts Dr Sundry / Cr Share Capital (+ Cr Income from Fees
* when an entry fee applies) and reveals the purchase entry in the transactions and passbook.
*
* The list shows the member's unapplied receipts for the business date, since a member settling an
* application at the counter has almost always paid for it today, and an older unapplied receipt of
* theirs is more likely money taken for something else. Nothing in it is ticked for the clerk — see
* {@link ngOnInit}.
*
* A receipt outside that narrow list is reached by voucher number through {@link lookupVoucher} —
* cash taken on an earlier day, or a slip the member is holding that is quicker to type than to find.
*/
@Component({
selector: 'mifosx-apply-collection-dialog',
@@ -40,6 +50,8 @@ import { CashierService } from 'app/cashier/cashier.service';
})
export class ApplyCollectionDialogComponent implements OnInit {
private cashierService = inject(CashierService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
loading = true;
submitting = false;
@@ -56,7 +68,7 @@ export class ApplyCollectionDialogComponent implements OnInit {
*/
selectedIds: number[] = [];
/** Voucher number quoted by the member, as an alternative to picking from the list. */
/** Voucher number quoted by the member, for a receipt the list does not show. */
voucherNo = '';
lookingUp = false;
@@ -75,15 +87,39 @@ export class ApplyCollectionDialogComponent implements OnInit {
)
.subscribe((res: any) => {
if (res) {
this.receipts = res.unappliedCollections || [];
if (this.receipts.length === 1) {
this.selectedIds = [this.receipts[0].id];
}
const today = this.dateUtils.formatDate(this.settingsService.businessDate, 'yyyy-MM-dd');
this.receipts = (res.unappliedCollections || []).filter(
(receipt: any) => this.collectionDateOf(receipt) === today
);
// Deliberately nothing ticked, not even when a single receipt is listed. Applying a
// collection turns the member's money into share capital, so which receipt it comes out of
// should be a choice the clerk made rather than one they inherited from a pre-ticked box
// and never looked at.
}
this.loading = false;
});
}
/**
* A receipt's collection date as {@code yyyy-MM-dd}.
*
* Normalises both shapes the API can send — that string, or Fineract's {@code [yyyy, m, d]} array
* — rather than assuming one, so the filter cannot silently match nothing if the serialiser
* behind this endpoint changes.
*/
private collectionDateOf(receipt: any): string {
const raw = receipt?.collection_date ?? receipt?.collectionDate;
if (Array.isArray(raw) && raw.length >= 3) {
const [
year,
month,
day
] = raw;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
return typeof raw === 'string' ? raw.slice(0, 10) : '';
}
isSelected(id: number): boolean {
return this.selectedIds.includes(id);
}
@@ -107,9 +143,13 @@ export class ApplyCollectionDialogComponent implements OnInit {
}
/**
* Resolves the voucher number the member quotes into a receipt. The lookup is not restricted to
* this member, so a voucher belonging to somebody else is surfaced here as a clear mismatch
* rather than failing later inside apply().
* Resolves the voucher number the member quotes into a receipt and adds it to the list.
*
* The list is deliberately narrow — this member, today, unapplied — so the search is how a
* receipt outside that gets settled: one taken yesterday, or one the member holds a slip for that
* is quicker to type than to find. The lookup itself is not restricted to this member, so a
* voucher belonging to somebody else is surfaced here as a clear mismatch rather than failing
* later inside apply().
*/
lookupVoucher(): void {
const voucher = this.voucherNo.trim();
@@ -137,9 +177,9 @@ export class ApplyCollectionDialogComponent implements OnInit {
this.errorMsg = `Voucher ${voucher} belongs to ${res.client_name}, not the holder of this share account.`;
return;
}
// Show it in the table (replacing any stale copy) and add it to the selection, rather than
// replacing what is already ticked — a looked-up voucher is usually the second half of a
// part-payment, not a change of mind about the first.
// Added to the table (replacing any stale copy) and ticked, unlike the listed receipts which
// are left for the clerk: typing a voucher number IS the deliberate choice of that receipt,
// so making them tick it again would be asking the same question twice.
this.receipts = [
res,
...this.receipts.filter((r) => r.id !== res.id)
@@ -150,6 +190,7 @@ export class ApplyCollectionDialogComponent implements OnInit {
res.id
];
}
this.voucherNo = '';
});
}
@@ -178,14 +178,16 @@
{{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
</td>
<td class="col-resolution text-left">
@if (isRedeem(txn)) {
{{ txn.resolutionNumber || '—' }}
<!-- The transaction's own resolution wherever it has one: a redeem always carries
one, and so does an additional-share purchase, authorised by its own board
meeting rather than the one the account was opened under. Only the original
application falls back to the account's resolution, having none of its own. -->
@if (txn.resolutionNumber) {
{{ txn.resolutionNumber }}
@if (txn.meetingDate) {
({{ txn.meetingDate | dateFormat }})
}
} @else {
<!-- Purchases are authorised by the share account's own board resolution, which is
held on the account rather than the transaction. -->
{{ resolutionNumber || '—' }}
@if (resolutionDate) {
({{ resolutionDate | dateFormat }})
@@ -14,6 +14,7 @@ import { CurrencyPipe } from '@angular/common';
import { MatIcon } from '@angular/material/icon';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { isSettledShareTransaction } from 'app/shares/share-transactions.util';
/**
* Share Account Passbook — a printable, chronological (oldest-first) ledger of every
@@ -81,9 +82,7 @@ export class SharesPassbookTabComponent implements OnInit {
this.totalApprovedShares = account.summary?.totalApprovedShares || 0;
this.currentMarketPrice = account.currentMarketPrice || 0;
const transactions = (account.purchasedShares || []).filter(
(transaction: any) => transaction.type.value !== 'Charge Payment'
);
const transactions = (account.purchasedShares || []).filter(isSettledShareTransaction);
this.allTransactions = this.recalculateRunningBalances(this.sortAscending(transactions));
this.filteredTransactions = [...this.allTransactions];
});
@@ -99,6 +99,48 @@
{{ cumulativeDividend | currency: sharesAccountData.currency.code : 'symbol-narrow' }}
</td>
</tr>
</tbody>
</table>
<!-- The allotments this certificate covers, one row each. A holding is built up over time —
the opening subscription, then each further tranche under its own board resolution — and
a certificate has to show when each block was allotted, not just the total. -->
@if (allotments.length > 0) {
<table class="allotment-table">
<thead>
<tr>
<th class="ta-left">Date of Allotment</th>
<th class="ta-right">Shares</th>
<th class="ta-right">Face Value</th>
<th class="ta-left">Share Numbers</th>
<th class="ta-left">Resolution No. / Meeting Date</th>
</tr>
</thead>
<tbody>
@for (row of allotments; track $index) {
<tr>
<td class="ta-left">{{ row.date | dateFormat }}</td>
<td class="ta-right">{{ row.shares }}</td>
<td class="ta-right">
{{ row.faceValue | currency: sharesAccountData.currency.code : 'symbol-narrow' }}
</td>
<td class="ta-left">
{{ row.shareNumbersFrom.toLocaleString() }} – {{ row.shareNumbersTo.toLocaleString() }}
</td>
<td class="ta-left">
{{ row.resolutionNumber || '—' }}
@if (row.meetingDate) {
<span class="allotment-meeting">({{ row.meetingDate | dateFormat }})</span>
}
</td>
</tr>
}
</tbody>
</table>
}
<table class="certificate-details-table">
<tbody>
<tr class="highlight-row">
<td colspan="2" class="lbl-col-highlight">TOTAL FACE VALUE</td>
<td colspan="2" class="val-col-highlight">
@@ -206,6 +206,13 @@
}
/* Certificate Data Table */
/* The details grid and the allotment schedule read as one block, so the gap that used to sit under
the details table now sits under whichever table comes last. */
.certificate-details-table:not(:last-of-type) {
margin-bottom: 0;
}
.certificate-details-table {
width: 100%;
border-collapse: collapse;
@@ -250,6 +257,47 @@
}
}
/* The allotment schedule — one row per block of shares allotted, in the same paper-and-ink palette
as the details grid so it reads as part of the certificate rather than a report pasted into it. */
.allotment-table {
position: relative;
z-index: 2; /* Above the watermark, like the other certificate content. */
width: 100%;
border-collapse: collapse;
margin-bottom: 40px;
th {
padding: 8px 12px;
border: 1px solid #ede8db;
background-color: #f7f5ee;
font-size: 11px;
font-weight: bold;
color: #7b6f63;
text-transform: uppercase;
letter-spacing: 0.6px;
}
td {
padding: 9px 12px;
border: 1px solid #ede8db;
font-size: 13px;
color: #322f2b;
}
.allotment-meeting {
color: #7b6f63;
font-size: 12px;
}
.ta-left {
text-align: left;
}
.ta-right {
text-align: right;
}
}
/* Certificate Footer Seal / Signatures */
.cert-footer {
display: flex;
@@ -61,6 +61,27 @@ export class ShareCertificateDialogComponent implements OnInit {
issueDate: Date = new Date();
todayDate: Date = new Date();
/**
* One row per approved purchase, oldest first — the allotments this certificate covers.
*
* A member's holding is built up over time: the opening subscription, then each further tranche,
* every one authorised by its own board resolution on its own date. Collapsing that into a single
* total and one "Date of Issue" loses exactly what a share certificate exists to record — when
* each block of shares was allotted, under which resolution, and which share numbers it covers.
* The running range is what lets a member's 20 shares be read as 10 allotted in August and 10 in
* September rather than as one undated block.
*/
allotments: {
date: any;
shares: number;
unitPrice: number;
faceValue: number;
resolutionNumber: string;
meetingDate: any;
shareNumbersFrom: number;
shareNumbersTo: number;
}[] = [];
ngOnInit() {
this.sharesAccountData = this.data.sharesAccountData;
this.clientId = String(this.data.clientId);
@@ -82,6 +103,18 @@ export class ShareCertificateDialogComponent implements OnInit {
const endNum = startNum + approvedShares - 1;
this.shareNumbersRange = approvedShares > 0 ? `${startNum.toLocaleString()} – ${endNum.toLocaleString()}` : 'None';
this.buildAllotments(startNum);
// The certificate is issued as of the most recent allotment, not the account's activation: a
// certificate reprinted after a further tranche covers those shares too, and dating it from
// activation would say the later shares were issued before they existed.
if (this.allotments.length > 0) {
const latest = this.allotments[this.allotments.length - 1];
if (latest.date) {
this.issueDate = new Date(latest.date);
}
}
// Calculate cumulative dividends
if (this.sharesAccountData.dividends) {
this.cumulativeDividend = this.sharesAccountData.dividends.reduce(
@@ -124,6 +157,48 @@ export class ShareCertificateDialogComponent implements OnInit {
});
}
/**
* Builds the allotment rows from the account's approved purchases.
*
* Only approved purchases count. A pending tranche has not been allotted — the board has not
* approved it and, on the cash and savings routes alike, the money is still sitting in Share
* Sundry — so putting it on a certificate would certify shares the member does not yet hold.
* Rejected ones and charge payments are not allotments at all.
*
* Share numbers run consecutively in purchase order, so each row's range picks up where the last
* left off and the ranges together reconcile to the account's total holding.
*
* @param startNum the first share number of the account's holding.
*/
private buildAllotments(startNum: number): void {
const purchases = (this.sharesAccountData.purchasedShares || [])
.filter(
(txn: any) => txn?.type?.value === 'Purchase' && txn?.status?.code === 'purchasedSharesStatusType.approved'
)
.slice()
.sort((a: any, b: any) => new Date(a.purchasedDate).getTime() - new Date(b.purchasedDate).getTime());
let nextShareNumber = startNum;
this.allotments = purchases.map((txn: any) => {
const shares = Number(txn.numberOfShares) || 0;
const unitPrice = Number(txn.purchasedPrice) || 0;
const from = nextShareNumber;
nextShareNumber += shares;
return {
date: txn.purchasedDate,
shares,
unitPrice,
faceValue: shares * unitPrice,
// The account's own resolution stands in for the opening subscription, which carries none of
// its own; each additional tranche has the resolution captured when it was applied for.
resolutionNumber: txn.resolutionNumber || this.sharesAccountData.resolutionNumber || '',
meetingDate: txn.meetingDate || this.sharesAccountData.resolutionDate || null,
shareNumbersFrom: from,
shareNumbersTo: from + shares - 1
};
});
}
formatAddress(addr: any): string {
if (!addr) return '';
return [
@@ -62,14 +62,16 @@
<ng-container matColumnDef="resolutionMeeting">
<th mat-header-cell *matHeaderCellDef>Resolution No. / Meeting Date</th>
<td mat-cell *matCellDef="let transaction">
@if (transaction.type.value === 'Redeem') {
{{ transaction.resolutionNumber || '—' }}
<!-- The transaction's own resolution wherever it has one: a redeem always carries one, and
so does an additional-share purchase, which is authorised by its own board meeting
rather than the one the account was opened under. Only the original application falls
back to the account's resolution, having none of its own. -->
@if (transaction.resolutionNumber) {
{{ transaction.resolutionNumber }}
@if (transaction.meetingDate) {
({{ transaction.meetingDate | dateFormat }})
}
} @else {
<!-- Purchases are authorised by the share account's own board resolution, which is held
on the account rather than the transaction. -->
{{ shareAccountData.resolutionNumber || '—' }}
@if (shareAccountData.resolutionDate) {
({{ shareAccountData.resolutionDate | dateFormat }})
@@ -24,6 +24,7 @@ import {
import { ActivatedRoute } from '@angular/router';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { isSettledShareTransaction } from 'app/shares/share-transactions.util';
/**
* Transactions Tab Component.
@@ -78,7 +79,7 @@ export class TransactionsTabComponent implements OnInit {
this.route.parent.data.subscribe((data: { sharesAccountData: any }) => {
this.shareAccountData = data.sharesAccountData;
this.transactionsData = this.shareAccountData.purchasedShares
.filter((transaction: any) => transaction.type.value !== 'Charge Payment')
.filter(isSettledShareTransaction)
.slice()
.sort((a: any, b: any) => new Date(a.purchasedDate).getTime() - new Date(b.purchasedDate).getTime());
+28
View File
@@ -122,6 +122,21 @@ export class SharesService {
return this.http.post(`/accounts/share/${accountId}`, data, { params: httpParams });
}
/**
* Whether a new additional-share tranche can be funded from the member's linked savings.
*
* Tranche-scoped, unlike `/sharesavingsdebit/eligibility`: that one values an account's first
* application by summing every purchase on the account, which for a further tranche would quote
* the member's whole holding history and call an already-settled account ineligible.
*
* @returns {Observable<any>} `{ eligible, reason, requestedShares, unitPrice, amount,
* savingsAccountNo, savingsBalance }`.
*/
getAdditionalSharesSavingsEligibility(accountId: string, requestedShares: number): Observable<any> {
const httpParams = new HttpParams().set('requestedShares', String(requestedShares));
return this.http.get(`/shareaccounts/${accountId}/additionalshares/savings-eligibility`, { params: httpParams });
}
/**
* @param {string} accountId Shares Account Id.
* @param {any} data Details edit payload.
@@ -146,6 +161,19 @@ export class SharesService {
return this.http.get(`/shareaccounts/edit/pending`);
}
/**
* Share accounts submitted and awaiting a checker's approval.
*
* Its own endpoint rather than `/accounts/share`, which only lists ACTIVE accounts and returns the
* whole account graph per row; this returns just the member, product, requested shares and value a
* checker decides on.
*
* @returns {Observable<any>} One row per pending account.
*/
getPendingShareAccounts(): Observable<any> {
return this.http.get('/shareaccounts/pending');
}
/**
* @param {string} accountId Shares Account Id.
* @returns {Observable<any>}
@@ -0,0 +1,107 @@
<!--
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/.
-->
<div class="psa-tab-content">
@if (showData) {
<div class="psa-action-bar">
<mat-form-field appearance="outline" class="psa-search-field">
<mat-icon matPrefix>search</mat-icon>
<input
#filterInput
matInput
placeholder="Search by account number, member or product"
(keyup)="applyFilter(filterInput.value)"
/>
</mat-form-field>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" class="modern-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No.</th>
<td mat-cell *matCellDef="let account" class="psa-mono">{{ account.accountNo }}</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Member</th>
<td mat-cell *matCellDef="let account">
{{ account.clientName }}
@if (account.officeName) {
<div class="psa-sub">{{ account.officeName }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef>Product</th>
<td mat-cell *matCellDef="let account">{{ account.productName }}</td>
</ng-container>
<ng-container matColumnDef="requestedShares">
<th mat-header-cell *matHeaderCellDef class="ta-right">Shares</th>
<td mat-cell *matCellDef="let account" class="ta-right psa-num">{{ account.requestedShares }}</td>
</ng-container>
<ng-container matColumnDef="requestedAmount">
<th mat-header-cell *matHeaderCellDef class="ta-right">Amount</th>
<td mat-cell *matCellDef="let account" class="ta-right psa-num">
{{ account.requestedAmount | number: '1.2-2' }}
</td>
</ng-container>
<!-- The board resolution the subscription was authorised under. A checker approving share
capital is confirming that authority exists, so it belongs in the row. -->
<ng-container matColumnDef="resolution">
<th mat-header-cell *matHeaderCellDef>Resolution</th>
<td mat-cell *matCellDef="let account">
{{ account.resolutionNumber || '—' }}
@if (account.resolutionDate) {
<div class="psa-sub">{{ formatDate(account.resolutionDate) }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="submittedDate">
<th mat-header-cell *matHeaderCellDef>Submitted</th>
<td mat-cell *matCellDef="let account">
{{ formatDate(account.submittedDate) }}
@if (account.submittedByUsername) {
<div class="psa-sub">by {{ account.submittedByUsername }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let account">
<button mat-stroked-button class="psa-btn psa-btn-view" (click)="viewAccount(account)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>View
</button>
<button mat-stroked-button class="psa-btn psa-btn-approve" (click)="approve(account)">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
<button mat-stroked-button class="psa-btn psa-btn-reject" (click)="reject(account)">
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
</div>
}
@if (!showData) {
<div class="empty-state">
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
<p class="empty-title">No pending share accounts</p>
<p class="empty-text">There are no share accounts awaiting approval at this time.</p>
</div>
}
</div>
@@ -0,0 +1,128 @@
/**
* 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/.
*/
$border: #e2e8f0;
$text-muted: #64748b;
.psa-tab-content {
padding: 16px 0;
}
.psa-action-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.psa-search-field {
flex: 1;
min-width: 200px;
max-width: 340px;
}
.psa-btn {
font-size: 11.5px;
font-weight: 600;
border-radius: 8px !important;
height: 32px;
padding: 0 10px;
margin-right: 6px;
fa-icon {
margin-right: 4px;
font-size: 11px;
}
}
.psa-btn-view {
border-color: #2563eb;
color: #2563eb;
&:hover {
background: #eff6ff;
}
}
.psa-btn-approve {
border-color: #16a34a;
color: #16a34a;
&:hover {
background: #f0fdf4;
}
}
.psa-btn-reject {
border-color: #dc2626;
color: #dc2626;
&:hover {
background: #fef2f2;
}
}
.psa-mono {
font-family: 'Courier New', monospace;
}
/* Tabular figures so share counts and amounts line up digit for digit down their columns — a queue
is scanned, and two subscriptions of very different sizes should look different at a glance. */
.psa-num {
font-variant-numeric: tabular-nums;
font-weight: 500;
white-space: nowrap;
}
/* The second line in a cell — branch under the member, meeting date under the resolution number,
who submitted it under the date. Kept with the value it qualifies rather than given a column of
its own, which would make an already wide table wider for something read together anyway. */
.psa-sub {
font-size: 11px;
color: $text-muted;
margin-top: 2px;
}
.ta-right {
text-align: right;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
background: #fff;
border: 1px dashed $border;
border-radius: 14px;
text-align: center;
margin: 16px 0;
.empty-icon {
font-size: 42px;
color: #cbd5e1;
margin-bottom: 14px;
}
.empty-title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
.empty-text {
margin: 0;
font-size: 12.5px;
color: $text-muted;
max-width: 380px;
line-height: 1.5;
}
}
@@ -0,0 +1,192 @@
/**
* 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, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { DecimalPipe } from '@angular/common';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { SharesService } from 'app/shares/shares.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Checker-facing list of share accounts submitted and awaiting approval.
*
* Mirrors PendingShareAccountEditsComponent's list + approve/reject pattern, but for whole accounts
* rather than edits to one. Shows what the decision turns on — the member, the product, how many
* shares they have subscribed for and what that comes to, and the board resolution behind it — so a
* checker can act from the list instead of opening every account to find out what it is.
*/
@Component({
selector: 'mifosx-pending-share-accounts',
templateUrl: './pending-share-accounts.component.html',
styleUrls: ['./pending-share-accounts.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon,
DecimalPipe
]
})
export class PendingShareAccountsComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private sharesService = inject(SharesService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
dataSource: MatTableDataSource<any>;
showData = false;
displayedColumns: string[] = [
'accountNo',
'clientName',
'productName',
'requestedShares',
'requestedAmount',
'resolution',
'submittedDate',
'actions'
];
constructor() {
this.route.data.subscribe((data: { pendingShareAccountsData: any }) => {
const items = data.pendingShareAccountsData || [];
this.showData = items.length > 0;
this.dataSource = new MatTableDataSource(items);
});
}
applyFilter(filterValue: string = '') {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
/**
* A date from the API rendered readably, whether it arrives as a string or as Fineract's
* {@code [yyyy, m, d]} array.
*/
formatDate(date: any): string {
if (!date) {
return '';
}
const d = Array.isArray(date) ? new Date(date[0], date[1] - 1, date[2]) : new Date(date);
return isNaN(d.getTime()) ? String(date) : d.toLocaleDateString('en-IN');
}
/** Opens the account itself, for a checker who wants more than the row shows. */
viewAccount(account: any): void {
this.router.navigate([
'/clients',
account.clientId,
'shares-accounts',
account.shareAccountId
]);
}
approve(account: any): void {
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Approve Share Account',
dialogContext:
`Approve share account "${account.accountNo}" for ${account.clientName} — ` +
`${account.requestedShares} shares at ${account.requestedAmount}?`,
type: 'basic'
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
// The approval date is the business date rather than the checker's choice: this queue is for
// clearing a submission, and a date field per row would be four extra clicks to type what
// the system already knows.
const data = {
approvedDate: this.dateUtils.formatDate(this.settingsService.businessDate, this.settingsService.dateFormat),
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
this.sharesService
.executeSharesAccountCommand(account.shareAccountId, 'approve', data)
.subscribe(() => this.reload());
}
});
}
reject(account: any): void {
const formfields: FormfieldBase[] = [
new InputBase({
controlName: 'rejectReason',
label: 'Reason for rejection',
value: '',
type: 'text',
required: false
})
];
const dialogRef = this.dialog.open(FormDialogComponent, {
data: {
title: `Reject Share Account ${account.accountNo}`,
formfields
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.data) {
// The reason is recorded as a note rather than dropped: a rejected subscription is the one a
// member is most likely to come back and ask about.
const data: any = {
rejectedDate: this.dateUtils.formatDate(this.settingsService.businessDate, this.settingsService.dateFormat),
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
const reason = response.data.value?.rejectReason?.trim();
if (reason) {
data.note = reason;
}
this.sharesService
.executeSharesAccountCommand(account.shareAccountId, 'reject', data)
.subscribe(() => this.reload());
}
});
}
reload(): void {
const url = this.router.url;
this.router
.navigateByUrl('/checker-inbox-and-tasks', { skipLocationChange: true })
.then(() => this.router.navigate([url]));
}
}
@@ -90,6 +90,17 @@
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Account Edits</span>
</a>
<!-- New share accounts awaiting approval. Next to Pending Share Account Edits, since the two are
the share equivalents of Pending Account and Pending Account Edits. -->
<a
class="ac-tab"
routerLink="./pending-share-accounts"
routerLinkActive="ac-tab--active"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Share Accounts</span>
</a>
<a
class="ac-tab"
routerLink="./pending-share-account-edits"
@@ -0,0 +1,29 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { Resolve } from '@angular/router';
import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from 'app/shares/shares.service';
/**
* Resolves the share accounts awaiting a checker's approval, for the Pending Share Accounts screen.
*
* An empty list on failure rather than a propagated error: this feeds one tab among many, and a
* failing endpoint should leave that tab empty rather than block the whole Approvals screen from
* loading.
*/
@Injectable()
export class GetPendingShareAccounts implements Resolve<any> {
private sharesService = inject(SharesService);
resolve(): Observable<any> {
return this.sharesService.getPendingShareAccounts().pipe(catchError(() => of([])));
}
}
+12 -1
View File
@@ -33,6 +33,7 @@ import { PendingSavingsProductsComponent } from './checker-inbox-and-tasks-tabs/
import { ChequebookRequestsComponent } from './checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component';
import { PendingSavingsAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component';
import { PendingShareAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-share-account-edits/pending-share-account-edits.component';
import { PendingShareAccountsComponent } from './checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component';
import { PendingClientEditsComponent } from './checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component';
import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component';
@@ -51,6 +52,7 @@ import { GetPendingSavingsProducts } from './common-resolvers/getPendingSavingsP
import { GetPendingSavingsAccountEdits } from './common-resolvers/getPendingSavingsAccountEdits.resolver';
import { GetPendingCurrentAccountEdits } from './common-resolvers/getPendingCurrentAccountEdits.resolver';
import { GetPendingShareAccountEdits } from './common-resolvers/getPendingShareAccountEdits.resolver';
import { GetPendingShareAccounts } from './common-resolvers/getPendingShareAccounts.resolver';
/** Tasks Routes */
const routes: Routes = [
@@ -144,6 +146,14 @@ const routes: Routes = [
path: 'pending-current-account-edits',
redirectTo: 'pending-savings-account-edits'
},
{
path: 'pending-share-accounts',
component: PendingShareAccountsComponent,
data: { title: 'Pending Share Accounts' },
resolve: {
pendingShareAccountsData: GetPendingShareAccounts
}
},
{
path: 'pending-share-account-edits',
component: PendingShareAccountEditsComponent,
@@ -264,7 +274,8 @@ const routes: Routes = [
GetPendingSavingsProducts,
GetPendingSavingsAccountEdits,
GetPendingCurrentAccountEdits,
GetPendingShareAccountEdits
GetPendingShareAccountEdits,
GetPendingShareAccounts
]
})
export class TasksRoutingModule {}
+1
View File
@@ -2598,6 +2598,7 @@
"Payment Allocation Rule": "Payment Allocation Rule",
"Payment Details": "Payment Details",
"Payment Due At": "Payment Due At",
"Payment Option": "Payment Option",
"Payment Type": "Payment Type",
"Payment Type Id": "Payment Type Id",
"Payment due as of": "Payment due as of",
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260825',
'hash': '7ef75c90'
'version': '260827',
'hash': '1273bdbe'
},
'allow_switching_backend_instance': true
};