fix(shares): mandatory GL+voucher entry for advance collection, split entry-fee verification from settlement
- 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:
@@ -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.`);
|
||||||
|
|||||||
+5
-2
@@ -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 = [];
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-5
@@ -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' }}
|
||||||
|
|||||||
+94
@@ -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;
|
||||||
|
|||||||
+152
-19
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user