diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts index 95aacd634..0200e5559 100644 --- a/src/app/cashier/cashier.service.ts +++ b/src/app/cashier/cashier.service.ts @@ -388,19 +388,31 @@ export class CashierService { } /** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */ - getShareAdvanceByVoucher(voucherNo: string, clientId?: number): Observable { + getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable { // 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 // (%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, - // which carries no member of its own. - let params = new HttpParams().set('voucherNo', voucherNo); + // clientId lets the backend adopt a cashier deposit made straight to a GL account, which + // carries no member of its own. glAccountNo is required — which GL account to search in is + // 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) { params = params.set('clientId', String(clientId)); } 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 { + 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 { + return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } }); + } + /** Receipts still sitting in Share Sundry, optionally for a single member. */ getUnappliedShareAdvances(clientId?: number): Observable { const suffix = clientId ? `?clientId=${clientId}` : ''; diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.html b/src/app/cashier/closing-cash-report/closing-cash-report.component.html index 1ae44ef03..503df7df1 100644 --- a/src/app/cashier/closing-cash-report/closing-cash-report.component.html +++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.html @@ -227,9 +227,8 @@ @if (r.txnSource === 'GL') { GL - } @else { - {{ r.voucherNo }} } + {{ r.voucherNo }}
{{ r.txnDate }}
diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html index d2127951f..4497dda2c 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.html @@ -109,6 +109,22 @@ Share Sundry against them, and approving this request moves it into share capital.

+
+ +
+
{{ lookingUpVoucher ? 'Finding…' : 'Find' }} diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss index 49017d355..9a9fa643c 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.scss @@ -49,6 +49,30 @@ $danger: #b3261e; 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 { display: flex; gap: 8px; diff --git a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts index 751167c6d..b071be51b 100644 --- a/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts +++ b/src/app/shares/shares-account-actions/apply-shares/apply-shares.component.ts @@ -8,7 +8,7 @@ /** Angular Imports */ 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 { MatRadioModule } from '@angular/material/radio'; 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'], imports: [ ...STANDALONE_SHARED_IMPORTS, + FormsModule, MatRadioModule, MatProgressSpinnerModule, MatIconModule @@ -99,6 +100,13 @@ export class ApplySharesComponent implements OnInit { lookingUpVoucher = false; 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. */ savingsEligibility: { eligible: boolean; @@ -120,6 +128,14 @@ export class ApplySharesComponent implements OnInit { this.createApplySharesAccountForm(); this.applySharesForm.get('unitPrice').patchValue(this.sharesAccountData.currentMarketPrice || ''); 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 { const trimmed = (voucherNo || '').trim(); - if (!trimmed || this.lookingUpVoucher) { + const glAccountNo = this.glAccountNo.trim(); + if (!trimmed || !glAccountNo || this.lookingUpVoucher) { return; } this.lookingUpVoucher = true; this.voucherError = ''; this.cashierService - .getShareAdvanceByVoucher(trimmed, this.sharesAccountData.clientId) + .getShareAdvanceByVoucher(trimmed, glAccountNo, this.sharesAccountData.clientId) .pipe( catchError((err: any) => { this.voucherError = this.readError(err, `Voucher ${trimmed} could not be found.`); diff --git a/src/app/shares/shares-account-stepper/shares-account-charges-step/shares-account-charges-step.component.ts b/src/app/shares/shares-account-stepper/shares-account-charges-step/shares-account-charges-step.component.ts index cd39dc79a..74fb50865 100644 --- a/src/app/shares/shares-account-stepper/shares-account-charges-step/shares-account-charges-step.component.ts +++ b/src/app/shares/shares-account-stepper/shares-account-charges-step/shares-account-charges-step.component.ts @@ -95,12 +95,15 @@ export class SharesAccountChargesStepComponent implements OnInit, OnChanges { ]; 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(() => { if (!this.isChargesPatched && this.sharesAccountTemplate.charges) { this.chargesDataSource = this.sharesAccountTemplate.charges; this.isChargesPatched = true; - } else { - this.chargesDataSource = []; } }); } diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html index 542808e3d..4733ce6b2 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.html @@ -15,27 +15,128 @@ instalments. The amount moves out of Share Sundry into share capital, and any entry fee is posted to fee income.

+ + @if (requirement) { +
+ + Required: {{ requirement.shareAmount | number: '1.2-2' }} (Shares) + @if (requirement.chargeAmount > 0) { + + {{ requirement.chargeAmount | number: '1.2-2' }} (Entry Fee) = + {{ requirement.required | number: '1.2-2' }} Total + } + + @if (selectedIds.length > 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 + } + + } +
+ } + -
- - + 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. --> +
+ +
+ +
+ +
+ + +
+ + @if (requirement && requirement.chargeAmount > 0) { +
+ + + @if (chargeReceipt) { +
+ {{ chargeShortfall > 0 ? 'error_outline' : 'check_circle_outline' }} + + {{ chargeReceipt.voucher_no || chargeReceipt.receipt_no }} + — {{ chargeReceipt.amount | number: '1.2-2' }} + @if (chargeShortfall > 0) { + (short by {{ chargeShortfall | number: '1.2-2' }}) + } @else { + — entry fee verified + } + + +
+ } @else { +
+ +
+ +
+ + +
+ +
+ Leave this blank if the fee was already paid as part of the share receipt above. +
+ } +
+ } + @if (errorMsg) {
error_outline @@ -106,8 +207,8 @@
- 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, unless it was verified as its own receipt + above. Anything paid over that is credited to the member's savings account.
} @@ -118,7 +219,7 @@ mat-raised-button color="primary" type="button" - [disabled]="selectedIds.length === 0 || submitting" + [disabled]="selectedIds.length === 0 || chargeShortfall > 0 || submitting" (click)="apply()" > {{ submitting ? 'Applying…' : 'Apply Collection' }} diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss index 014f0c3c8..b7546ba4a 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.scss @@ -10,6 +10,7 @@ $primary: #1a3a6b; $border: #d8e0ec; $muted: #6b7a90; $danger: #b3261e; +$success: #1e7d3c; .intro { font-size: 13px; @@ -17,6 +18,75 @@ $danger: #b3261e; 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 { display: flex; 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 { display: flex; align-items: center; diff --git a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts index a947c6ce5..7ceb51747 100644 --- a/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts +++ b/src/app/shares/shares-account-view/apply-collection-dialog/apply-collection-dialog.component.ts @@ -13,7 +13,7 @@ import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/materia import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { catchError } from 'rxjs/operators'; +import { catchError, map } from 'rxjs/operators'; import { of } from 'rxjs'; import { CashierService } from 'app/cashier/cashier.service'; import { SettingsService } from 'app/settings/settings.service'; @@ -72,6 +72,31 @@ export class ApplyCollectionDialogComponent implements OnInit { voucherNo = ''; 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); 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.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,45 +239,85 @@ export class ApplyCollectionDialogComponent implements OnInit { */ lookupVoucher(): void { const voucher = this.voucherNo.trim(); - if (!voucher || this.lookingUp) { + const glAccountNo = this.glAccountNo.trim(); + if (!voucher || !glAccountNo || this.lookingUp) { return; } this.lookingUp = true; this.errorMsg = ''; + this.findVoucher(voucher, glAccountNo).subscribe((res) => { + this.lookingUp = false; + if (!res) { + return; + } + // 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) + ]; + if (!this.isSelected(res.id)) { + this.selectedIds = [ + ...this.selectedIds, + res.id + ]; + } + this.voucherNo = ''; + }); + } - 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; - if (!res) { - return; - } + /** + * 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; + return null; } - // 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) - ]; - if (!this.isSelected(res.id)) { - this.selectedIds = [ - ...this.selectedIds, - res.id - ]; - } - this.voucherNo = ''; - }); + return res; + }), + catchError((err) => { + this.errorMsg = this.readError(err, `Voucher ${voucher} could not be found.`); + return of(null); + }) + ); } apply(): void { @@ -202,14 +328,21 @@ export class ApplyCollectionDialogComponent implements OnInit { this.errorMsg = ''; // 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 [ leadId, ...additionalCollectionIds ] = this.selectedIds; + const data: any = { shareAccountId: this.data.shareAccountId, additionalCollectionIds }; + if (this.chargeReceipt) { + data.chargeCollectionId = this.chargeReceipt.id; + } + this.cashierService - .applyShareAdvance(leadId, { shareAccountId: this.data.shareAccountId, additionalCollectionIds }) + .applyShareAdvance(leadId, data) .pipe( catchError((err) => { this.errorMsg = this.readError(err, 'Applying the collection failed.'); diff --git a/src/app/shares/shares-account-view/shares-account-view.component.ts b/src/app/shares/shares-account-view/shares-account-view.component.ts index 68e28fb52..ccbb346a1 100644 --- a/src/app/shares/shares-account-view/shares-account-view.component.ts +++ b/src/app/shares/shares-account-view/shares-account-view.component.ts @@ -158,6 +158,13 @@ export class SharesAccountViewComponent implements OnInit { this.buttonConfig.removeButton('Approve 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'); + } } } diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 49a49be1e..938c9c82a 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260829', - 'hash': 'fe81d159' + 'version': '260830', + 'hash': '9f95d011' }, 'allow_switching_backend_instance': true };