fix(shares): mandatory GL+voucher entry for advance collection, split entry-fee verification from settlement
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

- Apply Collection dialog: replace product-config auto-resolved GL account with a required GL +
  voucher search (mirrored for Share and Entry Fee receipts). The entry-fee receipt is tracked
  separately from the share receipts and sent as its own field so its amount is never swept into the
  Sundry-side total or double-posted — it only verifies the fee was paid, since that money already
  sits wherever the cashier deposited it.
- Shows the account's required total (share value + entry fee) and live coverage up front, instead of
  only after a failed apply.
- Apply Additional Shares: same mandatory GL + voucher pattern for its by-voucher lookup, plus a GL
  account picker fed by the new /shareadvancecollection/glaccounts endpoint.
- shares-account-charges-step: fixed a race where the currency-control subscription (meant only for
  the edit-an-existing-application flow) wiped out product charges that ngOnChanges had just seeded
  from the same product-selection event, leaving new accounts with no charges.
- shares-account-view: hide "Share Certificate" until collection_applied is true, so a certificate
  can't be printed for shares nobody has actually paid for yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 20:05:11 +05:30
co-authored by Claude Sonnet 5
parent 9f95d01196
commit 44b23ef110
11 changed files with 475 additions and 69 deletions
+16 -4
View File
@@ -388,19 +388,31 @@ export class CashierService {
} }
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */ /** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
getShareAdvanceByVoucher(voucherNo: string, clientId?: number): Observable<any> { getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> {
// The voucher travels as a query parameter, not a path segment: numbers are formatted // The voucher travels as a query parameter, not a path segment: numbers are formatted
// CR/001/2108/0001, and Spring Security's strict firewall rejects the encoded slashes // CR/001/2108/0001, and Spring Security's strict firewall rejects the encoded slashes
// (%2F) outright with a 400 before the request ever reaches the resource. // (%2F) outright with a 400 before the request ever reaches the resource.
// clientId lets the backend adopt a cashier deposit made straight to the Share Sundry GL, // clientId lets the backend adopt a cashier deposit made straight to a GL account, which
// which carries no member of its own. // carries no member of its own. glAccountNo is required — which GL account to search in is
let params = new HttpParams().set('voucherNo', voucherNo); // always chosen by hand, never resolved from a share product's accounting setup, so a broken
// or missing product mapping can never make a genuine voucher unreachable.
let params = new HttpParams().set('voucherNo', voucherNo).set('glAccountNo', glAccountNo);
if (clientId) { if (clientId) {
params = params.set('clientId', String(clientId)); params = params.set('clientId', String(clientId));
} }
return this.http.get('/shareadvancecollection/by-voucher', { params }); return this.http.get('/shareadvancecollection/by-voucher', { params });
} }
/** GL accounts selectable when searching for a voucher, for the picker on the Apply Collection screen. */
getShareAdvanceGlAccounts(): Observable<any> {
return this.http.get('/shareadvancecollection/glaccounts');
}
/** Share value + any outstanding entry fee for one share account, shown before a voucher search. */
getShareAdvanceRequirement(shareAccountId: number): Observable<any> {
return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } });
}
/** Receipts still sitting in Share Sundry, optionally for a single member. */ /** Receipts still sitting in Share Sundry, optionally for a single member. */
getUnappliedShareAdvances(clientId?: number): Observable<any> { getUnappliedShareAdvances(clientId?: number): Observable<any> {
const suffix = clientId ? `?clientId=${clientId}` : ''; const suffix = clientId ? `?clientId=${clientId}` : '';
@@ -227,9 +227,8 @@
<td class="gl-voucher"> <td class="gl-voucher">
@if (r.txnSource === 'GL') { @if (r.txnSource === 'GL') {
<span class="gl-badge">GL</span> <span class="gl-badge">GL</span>
} @else {
{{ r.voucherNo }}
} }
{{ r.voucherNo }}
</td> </td>
<td class="gl-date"> <td class="gl-date">
<div>{{ r.txnDate }}</div> <div>{{ r.txnDate }}</div>
@@ -109,6 +109,22 @@
Share Sundry against them, and approving this request moves it into share capital. Share Sundry against them, and approving this request moves it into share capital.
</p> </p>
<div class="gl-select-field">
<select
class="gl-select"
[(ngModel)]="glAccountNo"
[ngModelOptions]="{ standalone: true }"
[disabled]="lookingUpVoucher || submitting || loadingGlAccounts"
>
<option value="" disabled>
{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}
</option>
@for (gl of glAccounts; track gl.id) {
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
}
</select>
</div>
<div class="voucher-lookup"> <div class="voucher-lookup">
<input <input
#voucherInput #voucherInput
@@ -121,7 +137,7 @@
<button <button
mat-stroked-button mat-stroked-button
type="button" type="button"
[disabled]="!voucherInput.value.trim() || lookingUpVoucher || submitting" [disabled]="!voucherInput.value.trim() || !glAccountNo.trim() || lookingUpVoucher || submitting"
(click)="lookupVoucher(voucherInput.value)" (click)="lookupVoucher(voucherInput.value)"
> >
{{ lookingUpVoucher ? 'Finding…' : 'Find' }} {{ lookingUpVoucher ? 'Finding…' : 'Find' }}
@@ -49,6 +49,30 @@ $danger: #b3261e;
margin: 0 0 12px; margin: 0 0 12px;
} }
.gl-select-field {
margin: 0 0 10px;
}
.gl-select {
width: 100%;
padding: 9px 11px;
border: 1px solid $border;
border-radius: 6px;
font-size: 13px;
color: $primary;
background: #fff;
&:focus {
outline: none;
border-color: $primary;
}
&:disabled {
background: #f4f7fb;
color: $muted;
}
}
.voucher-lookup { .voucher-lookup {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -8,7 +8,7 @@
/** Angular Imports */ /** Angular Imports */
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { MatRadioModule } from '@angular/material/radio'; import { MatRadioModule } from '@angular/material/radio';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@@ -55,6 +55,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./apply-shares.component.scss'], styleUrls: ['./apply-shares.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatRadioModule, MatRadioModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
MatIconModule MatIconModule
@@ -99,6 +100,13 @@ export class ApplySharesComponent implements OnInit {
lookingUpVoucher = false; lookingUpVoucher = false;
voucherError = ''; voucherError = '';
/** GL account the deposit was posted to — required, and always picked by hand. Never resolved
* from a share product's accounting setup, so a broken or missing product mapping can never
* make a genuine voucher unreachable. */
glAccountNo = '';
glAccounts: any[] = [];
loadingGlAccounts = true;
/** What the member's savings can cover, for the savings route. */ /** What the member's savings can cover, for the savings route. */
savingsEligibility: { savingsEligibility: {
eligible: boolean; eligible: boolean;
@@ -120,6 +128,14 @@ export class ApplySharesComponent implements OnInit {
this.createApplySharesAccountForm(); this.createApplySharesAccountForm();
this.applySharesForm.get('unitPrice').patchValue(this.sharesAccountData.currentMarketPrice || ''); this.applySharesForm.get('unitPrice').patchValue(this.sharesAccountData.currentMarketPrice || '');
this.applySharesForm.get('amount').patchValue(this.amount || ''); this.applySharesForm.get('amount').patchValue(this.amount || '');
this.cashierService
.getShareAdvanceGlAccounts()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.glAccounts = res?.glAccounts || [];
this.loadingGlAccounts = false;
});
} }
/** /**
@@ -266,14 +282,15 @@ export class ApplySharesComponent implements OnInit {
*/ */
lookupVoucher(voucherNo: string): void { lookupVoucher(voucherNo: string): void {
const trimmed = (voucherNo || '').trim(); const trimmed = (voucherNo || '').trim();
if (!trimmed || this.lookingUpVoucher) { const glAccountNo = this.glAccountNo.trim();
if (!trimmed || !glAccountNo || this.lookingUpVoucher) {
return; return;
} }
this.lookingUpVoucher = true; this.lookingUpVoucher = true;
this.voucherError = ''; this.voucherError = '';
this.cashierService this.cashierService
.getShareAdvanceByVoucher(trimmed, this.sharesAccountData.clientId) .getShareAdvanceByVoucher(trimmed, glAccountNo, this.sharesAccountData.clientId)
.pipe( .pipe(
catchError((err: any) => { catchError((err: any) => {
this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`); this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`);
@@ -95,12 +95,15 @@ export class SharesAccountChargesStepComponent implements OnInit, OnChanges {
]; ];
ngOnInit() { ngOnInit() {
// Only relevant to the "edit an existing application" flow, where sharesAccountTemplate.charges
// carries the charges the account was actually submitted with, patched back in once the currency
// control settles. For a brand-new account, sharesAccountTemplate never carries a charges array at
// all -- this must NOT fall back to wiping chargesDataSource, or it stomps the product's own
// charges that ngOnChanges just seeded moments earlier from the same product-selection event.
this.currencyCode.valueChanges.subscribe(() => { this.currencyCode.valueChanges.subscribe(() => {
if (!this.isChargesPatched && this.sharesAccountTemplate.charges) { if (!this.isChargesPatched && this.sharesAccountTemplate.charges) {
this.chargesDataSource = this.sharesAccountTemplate.charges; this.chargesDataSource = this.sharesAccountTemplate.charges;
this.isChargesPatched = true; this.isChargesPatched = true;
} else {
this.chargesDataSource = [];
} }
}); });
} }
@@ -15,8 +15,45 @@
instalments. The amount moves out of Share Sundry into share capital, and any entry fee is posted to fee income. instalments. The amount moves out of Share Sundry into share capital, and any entry fee is posted to fee income.
</p> </p>
<!-- What this account needs, up front — so the counter knows the full figure, and that it may take
a second receipt for the entry fee, before a search ever comes up short. -->
@if (requirement) {
<div class="requirement-summary">
<span class="requirement-line">
Required: <b>{{ requirement.shareAmount | number: '1.2-2' }}</b> (Shares)
@if (requirement.chargeAmount > 0) {
+ <b>{{ requirement.chargeAmount | number: '1.2-2' }}</b> (Entry Fee) =
<b>{{ requirement.required | number: '1.2-2' }}</b> Total
}
</span>
@if (selectedIds.length > 0) {
<span class="requirement-progress" [class.covered]="shortfall === 0">
@if (shortfall === 0) {
Share receipts covered
} @else {
{{ selectedTotal | number: '1.2-2' }} of {{ shareRequired | number: '1.2-2' }} collected —
{{ shortfall | number: '1.2-2' }} short
}
</span>
}
</div>
}
<!-- For a receipt the list does not carry — one taken on an earlier day, or a slip the member is <!-- 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. --> holding that is quicker to type than to find. Both the GL account the deposit was posted to
and the voucher number are required — never resolved from a share product's own accounting
setup, so a broken or missing product mapping can never make a genuine voucher unreachable. -->
<div class="voucher-section">
<div class="voucher-section-label">Share Receipt</div>
<div class="gl-select-field">
<select class="gl-select" [(ngModel)]="glAccountNo" [disabled]="lookingUp || submitting || loadingGlAccounts">
<option value="" disabled>{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}</option>
@for (gl of glAccounts; track gl.id) {
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
}
</select>
</div>
<div class="voucher-lookup"> <div class="voucher-lookup">
<input <input
type="text" type="text"
@@ -29,12 +66,76 @@
<button <button
mat-stroked-button mat-stroked-button
type="button" type="button"
[disabled]="!voucherNo.trim() || lookingUp || submitting" [disabled]="!voucherNo.trim() || !glAccountNo.trim() || lookingUp || submitting"
(click)="lookupVoucher()" (click)="lookupVoucher()"
> >
{{ lookingUp ? 'Finding…' : 'Find' }} {{ lookingUp ? 'Finding…' : 'Find' }}
</button> </button>
</div> </div>
</div>
<!-- Shown only when this account actually has an outstanding fee -- a member sometimes pays it as
a second receipt into a different GL account rather than folding it into the share-value
deposit. This search only ever verifies that receipt exists; it never moves its money -- that
stays exactly where the cashier already deposited it, unlike the share receipts above which
genuinely move out of Sundry. -->
@if (requirement && requirement.chargeAmount > 0) {
<div class="voucher-section">
<div class="voucher-section-label">Entry Fee Receipt — verification only, no GL entry is posted for it</div>
@if (chargeReceipt) {
<div class="charge-receipt-found" [class.short]="chargeShortfall > 0">
<mat-icon>{{ chargeShortfall > 0 ? 'error_outline' : 'check_circle_outline' }}</mat-icon>
<span class="charge-receipt-detail">
<b class="mono">{{ chargeReceipt.voucher_no || chargeReceipt.receipt_no }}</b>
— {{ chargeReceipt.amount | number: '1.2-2' }}
@if (chargeShortfall > 0) {
(short by {{ chargeShortfall | number: '1.2-2' }})
} @else {
— entry fee verified
}
</span>
<button mat-button type="button" [disabled]="submitting" (click)="clearChargeReceipt()">Change</button>
</div>
} @else {
<div class="gl-select-field">
<select
class="gl-select"
[(ngModel)]="chargeGlAccountNo"
[disabled]="lookingUpCharge || submitting || loadingGlAccounts"
>
<option value="" disabled>{{ loadingGlAccounts ? 'Loading GL accounts…' : 'Select GL account…' }}</option>
@for (gl of glAccounts; track gl.id) {
<option [value]="gl.gl_code">{{ gl.gl_code }} — {{ gl.name }}</option>
}
</select>
</div>
<div class="voucher-lookup">
<input
type="text"
class="voucher-input"
placeholder="Voucher no. (e.g. CR/001/2608/0002)"
[(ngModel)]="chargeVoucherNo"
[disabled]="lookingUpCharge || submitting"
(keyup.enter)="lookupChargeVoucher()"
/>
<button
mat-stroked-button
type="button"
[disabled]="!chargeVoucherNo.trim() || !chargeGlAccountNo.trim() || lookingUpCharge || submitting"
(click)="lookupChargeVoucher()"
>
{{ lookingUpCharge ? 'Finding…' : 'Find' }}
</button>
</div>
<div class="charge-receipt-hint">
Leave this blank if the fee was already paid as part of the share receipt above.
</div>
}
</div>
}
@if (errorMsg) { @if (errorMsg) {
<div class="alert-error"> <div class="alert-error">
@@ -106,8 +207,8 @@
</table> </table>
<div class="note"> <div class="note">
Their total must cover the account's share value plus any entry fee — anything paid over that is credited to the Their total must cover the account's share value — plus any entry fee, unless it was verified as its own receipt
member's savings account. above. Anything paid over that is credited to the member's savings account.
</div> </div>
} }
</mat-dialog-content> </mat-dialog-content>
@@ -118,7 +219,7 @@
mat-raised-button mat-raised-button
color="primary" color="primary"
type="button" type="button"
[disabled]="selectedIds.length === 0 || submitting" [disabled]="selectedIds.length === 0 || chargeShortfall > 0 || submitting"
(click)="apply()" (click)="apply()"
> >
{{ submitting ? 'Applying…' : 'Apply Collection' }} {{ submitting ? 'Applying…' : 'Apply Collection' }}
@@ -10,6 +10,7 @@ $primary: #1a3a6b;
$border: #d8e0ec; $border: #d8e0ec;
$muted: #6b7a90; $muted: #6b7a90;
$danger: #b3261e; $danger: #b3261e;
$success: #1e7d3c;
.intro { .intro {
font-size: 13px; font-size: 13px;
@@ -17,6 +18,75 @@ $danger: #b3261e;
margin: 0 0 12px; margin: 0 0 12px;
} }
.requirement-summary {
display: flex;
flex-direction: column;
gap: 4px;
background: #f4f7fb;
border: 1px solid $border;
border-radius: 6px;
padding: 10px 12px;
font-size: 13px;
color: $primary;
margin: 0 0 14px;
}
.requirement-progress {
font-size: 12px;
font-weight: 500;
color: $danger;
&.covered {
color: $success;
}
}
.voucher-section {
margin: 0 0 14px;
}
.voucher-section-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: $muted;
margin: 0 0 6px;
}
.charge-receipt-found {
display: flex;
align-items: center;
gap: 8px;
background: #eefaf1;
border: 1px solid #bfe6cb;
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
color: $success;
&.short {
background: #fdecea;
border-color: #f5c6c2;
color: $danger;
}
mat-icon {
flex-shrink: 0;
}
}
.charge-receipt-detail {
flex: 1;
min-width: 0;
}
.charge-receipt-hint {
font-size: 11px;
color: $muted;
margin-top: 4px;
}
.voucher-lookup { .voucher-lookup {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -46,6 +116,30 @@ $danger: #b3261e;
} }
} }
.gl-select-field {
margin: 0 0 10px;
}
.gl-select {
width: 100%;
padding: 9px 11px;
border: 1px solid $border;
border-radius: 6px;
font-size: 13px;
color: $primary;
background: #fff;
&:focus {
outline: none;
border-color: $primary;
}
&:disabled {
background: #f4f7fb;
color: $muted;
}
}
.loading { .loading {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -13,7 +13,7 @@ import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/materia
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { catchError } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { CashierService } from 'app/cashier/cashier.service'; import { CashierService } from 'app/cashier/cashier.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
@@ -72,6 +72,31 @@ export class ApplyCollectionDialogComponent implements OnInit {
voucherNo = ''; voucherNo = '';
lookingUp = false; lookingUp = false;
/** GL account the deposit was posted to — required, and always picked by hand. Never resolved
* from a share product's accounting setup, so a broken or missing product mapping can never
* make a genuine voucher unreachable. */
glAccountNo = '';
glAccounts: any[] = [];
loadingGlAccounts = true;
/** A second, separately-tracked receipt search for the entry fee — a member sometimes pays the
* share value and the fee as two separate receipts, posted to two different GL accounts, so one
* voucher search covering only the share value would never reach the fee on its own.
*
* Kept entirely apart from {@link receipts}/{@link selectedIds}: this receipt is sent to the
* server as its own field (see {@link apply}) so it is never swept into the Sundry-side sum and
* never gets a GL entry of its own — it is proof the fee was paid, not money that needs to move,
* since it never sat in Sundry in the first place. */
chargeVoucherNo = '';
chargeGlAccountNo = '';
lookingUpCharge = false;
chargeReceipt: any = null;
/** What this account needs before a collection can be applied — fetched up front so the counter
* knows the full figure (and that it may take two receipts) before a search ever comes up short. */
requirement: { shareAmount: number; chargeAmount: number; required: number } | null = null;
loadingRequirement = true;
public dialogRef = inject(MatDialogRef<ApplyCollectionDialogComponent>); public dialogRef = inject(MatDialogRef<ApplyCollectionDialogComponent>);
public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA); public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA);
@@ -98,6 +123,67 @@ export class ApplyCollectionDialogComponent implements OnInit {
} }
this.loading = false; this.loading = false;
}); });
this.cashierService
.getShareAdvanceGlAccounts()
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Could not load the GL account list.');
this.loadingGlAccounts = false;
return of(null);
})
)
.subscribe((res: any) => {
this.glAccounts = res?.glAccounts || [];
this.loadingGlAccounts = false;
});
this.cashierService
.getShareAdvanceRequirement(this.data.shareAccountId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
if (res) {
this.requirement = {
shareAmount: Number(res.shareAmount || 0),
chargeAmount: Number(res.chargeAmount || 0),
required: Number(res.required || 0)
};
}
this.loadingRequirement = false;
});
}
/**
* What the share receipts still need to reach — the share value alone once the entry fee has its
* own verified receipt, otherwise the combined share-plus-fee total, matching exactly what the
* server checks the Sundry-side receipts against in {@link apply}.
*/
get shareRequired(): number {
if (!this.requirement) {
return 0;
}
return this.chargeReceipt ? this.requirement.shareAmount : this.requirement.required;
}
/** What the share receipts are still missing, zero once fully covered. */
get shortfall(): number {
const short = this.shareRequired - this.selectedTotal;
return short > 0 ? short : 0;
}
/** Whether a verified entry fee receipt still needs to be found — false when there is no fee, or
* when the fee was already folded into the share receipt(s) above. */
get chargeUnverified(): boolean {
return !!this.requirement && this.requirement.chargeAmount > 0 && !this.chargeReceipt;
}
/** Whether the found entry fee receipt actually covers the fee. */
get chargeShortfall(): number {
if (!this.requirement || !this.chargeReceipt) {
return 0;
}
const short = this.requirement.chargeAmount - Number(this.chargeReceipt.amount || 0);
return short > 0 ? short : 0;
} }
/** /**
@@ -153,30 +239,17 @@ export class ApplyCollectionDialogComponent implements OnInit {
*/ */
lookupVoucher(): void { lookupVoucher(): void {
const voucher = this.voucherNo.trim(); const voucher = this.voucherNo.trim();
if (!voucher || this.lookingUp) { const glAccountNo = this.glAccountNo.trim();
if (!voucher || !glAccountNo || this.lookingUp) {
return; return;
} }
this.lookingUp = true; this.lookingUp = true;
this.errorMsg = ''; this.errorMsg = '';
this.findVoucher(voucher, glAccountNo).subscribe((res) => {
this.cashierService
.getShareAdvanceByVoucher(voucher, this.data.clientId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found.`);
this.lookingUp = false;
return of(null);
})
)
.subscribe((res: any) => {
this.lookingUp = false; this.lookingUp = false;
if (!res) { if (!res) {
return; return;
} }
if (Number(res.client_id) !== Number(this.data.clientId)) {
this.errorMsg = `Voucher ${voucher} belongs to ${res.client_name}, not the holder of this share account.`;
return;
}
// Added to the table (replacing any stale copy) and ticked, unlike the listed receipts which // 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, // 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. // so making them tick it again would be asking the same question twice.
@@ -194,6 +267,59 @@ export class ApplyCollectionDialogComponent implements OnInit {
}); });
} }
/**
* Same lookup as {@link lookupVoucher}, against its own GL account and voucher fields — for a
* receipt that covers the entry fee rather than the share value.
*
* Deliberately NOT added to {@link receipts}/{@link selectedIds}: this receipt is kept on its own
* ({@link chargeReceipt}) and sent to the server as a distinct field, so it is never swept into the
* Sundry-side total and never gets a settlement GL entry of its own — it proves the fee was paid,
* it does not move money that never sat in Sundry to begin with.
*/
lookupChargeVoucher(): void {
const voucher = this.chargeVoucherNo.trim();
const glAccountNo = this.chargeGlAccountNo.trim();
if (!voucher || !glAccountNo || this.lookingUpCharge) {
return;
}
this.lookingUpCharge = true;
this.errorMsg = '';
this.findVoucher(voucher, glAccountNo).subscribe((res) => {
this.lookingUpCharge = false;
if (!res) {
return;
}
this.chargeReceipt = res;
this.chargeVoucherNo = '';
});
}
/** Clears a wrongly-found entry fee receipt so a different voucher can be searched instead. */
clearChargeReceipt(): void {
this.chargeReceipt = null;
}
/**
* Resolves one voucher number into the receipt it names, common to both lookups above. Each caller
* decides what to do with the result — added to the shared table for a share receipt, kept on its
* own for the entry fee — since the two are never allowed to mix into the same sum.
*/
private findVoucher(voucher: string, glAccountNo: string) {
return this.cashierService.getShareAdvanceByVoucher(voucher, glAccountNo, this.data.clientId).pipe(
map((res: any) => {
if (Number(res.client_id) !== Number(this.data.clientId)) {
this.errorMsg = `Voucher ${voucher} belongs to ${res.client_name}, not the holder of this share account.`;
return null;
}
return res;
}),
catchError((err) => {
this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found.`);
return of(null);
})
);
}
apply(): void { apply(): void {
if (this.selectedIds.length === 0 || this.submitting) { if (this.selectedIds.length === 0 || this.submitting) {
return; return;
@@ -202,14 +328,21 @@ export class ApplyCollectionDialogComponent implements OnInit {
this.errorMsg = ''; this.errorMsg = '';
// The first receipt leads on the URL; the rest ride along in the body, and the server settles // The first receipt leads on the URL; the rest ride along in the body, and the server settles
// them as one transaction. // them as one transaction. The entry fee receipt, when one was found, rides along as its own
// field — never folded into additionalCollectionIds, so the server never sums it into the
// Sundry-side total or posts a GL entry for it.
const [ const [
leadId, leadId,
...additionalCollectionIds ...additionalCollectionIds
] = this.selectedIds; ] = this.selectedIds;
const data: any = { shareAccountId: this.data.shareAccountId, additionalCollectionIds };
if (this.chargeReceipt) {
data.chargeCollectionId = this.chargeReceipt.id;
}
this.cashierService this.cashierService
.applyShareAdvance(leadId, { shareAccountId: this.data.shareAccountId, additionalCollectionIds }) .applyShareAdvance(leadId, data)
.pipe( .pipe(
catchError((err) => { catchError((err) => {
this.errorMsg = this.readError(err, 'Applying the collection failed.'); this.errorMsg = this.readError(err, 'Applying the collection failed.');
@@ -158,6 +158,13 @@ export class SharesAccountViewComponent implements OnInit {
this.buttonConfig.removeButton('Approve Additional Shares'); this.buttonConfig.removeButton('Approve Additional Shares');
this.buttonConfig.removeButton('Reject Additional Shares'); this.buttonConfig.removeButton('Reject Additional Shares');
} }
// An Active account can carry "approved" shares nobody has actually paid for yet -- account
// creation, approval and activation post no GL of their own; only applying a Share Advance
// Collection does (see isSettled). A certificate for shares still unsettled would certify
// capital that was never issued.
if (!this.isSettled) {
this.buttonConfig.removeOption('Share Certificate');
}
} }
} }
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '260829', 'version': '260830',
'hash': 'fe81d159' 'hash': '9f95d011'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };