From 83e58376cb0db5470d14a11f4bcfd1e54da9eee6 Mon Sep 17 00:00:00 2001 From: keshav Date: Thu, 27 Aug 2026 10:23:25 +0530 Subject: [PATCH] feat: implement share account actions, view components, and pending checker tasks --- src/app/shares/share-transactions.util.ts | 34 ++ .../activate-shares-account.component.ts | 3 +- .../apply-shares/apply-shares.component.html | 227 ++++++++++- .../apply-shares/apply-shares.component.scss | 201 ++++++++++ .../apply-shares/apply-shares.component.ts | 367 ++++++++++++++++-- .../approve-shares-account.component.ts | 5 +- .../approve-shares.component.html | 23 ++ .../approve-shares.component.scss | 8 + .../approve-shares.component.ts | 63 ++- .../close-shares-account.component.ts | 3 +- .../redeem-shares/redeem-shares.component.ts | 3 +- .../reject-shares-account.component.ts | 3 +- .../shares-account-details-step.component.ts | 5 +- .../apply-collection-dialog.component.html | 43 +- .../apply-collection-dialog.component.scss | 33 +- .../apply-collection-dialog.component.ts | 65 +++- .../passbook-tab/passbook-tab.component.html | 10 +- .../passbook-tab/passbook-tab.component.ts | 5 +- .../share-certificate-dialog.component.html | 42 ++ .../share-certificate-dialog.component.scss | 48 +++ .../share-certificate-dialog.component.ts | 75 ++++ .../transactions-tab.component.html | 10 +- .../transactions-tab.component.ts | 3 +- src/app/shares/shares.service.ts | 28 ++ .../pending-share-accounts.component.html | 107 +++++ .../pending-share-accounts.component.scss | 128 ++++++ .../pending-share-accounts.component.ts | 192 +++++++++ .../checker-inbox-and-tasks.component.html | 11 + .../getPendingShareAccounts.resolver.ts | 29 ++ src/app/tasks/tasks-routing.module.ts | 13 +- src/assets/translations/en-US.json | 1 + src/environments/.env.ts | 4 +- 32 files changed, 1699 insertions(+), 93 deletions(-) create mode 100644 src/app/shares/share-transactions.util.ts create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.html create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.scss create mode 100644 src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.ts create mode 100644 src/app/tasks/common-resolvers/getPendingShareAccounts.resolver.ts diff --git a/src/app/shares/share-transactions.util.ts b/src/app/shares/share-transactions.util.ts new file mode 100644 index 000000000..94dfe0750 --- /dev/null +++ b/src/app/shares/share-transactions.util.ts @@ -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'; +} diff --git a/src/app/shares/shares-account-actions/activate-shares-account/activate-shares-account.component.ts b/src/app/shares/shares-account-actions/activate-shares-account/activate-shares-account.component.ts index b712925f4..a323085be 100644 --- a/src/app/shares/shares-account-actions/activate-shares-account/activate-shares-account.component.ts +++ b/src/app/shares/shares-account-actions/activate-shares-account/activate-shares-account.component.ts @@ -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 ] }); 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 1a59850e2..d2127951f 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 @@ -31,9 +31,42 @@ } + + + {{ 'labels.inputs.Resolution Number' | translate }} + + @if (applySharesForm.controls.resolutionNumber.hasError('required')) { + + {{ 'labels.inputs.Resolution Number' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + + {{ 'labels.inputs.Resolution Date' | translate }} + + + + @if (applySharesForm.controls.resolutionDate.hasError('required')) { + + {{ 'labels.inputs.Resolution Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + {{ 'labels.inputs.Total No. of Shares' | translate }} - + @if (applySharesForm.controls.requestedShares.hasError('required')) { {{ 'labels.inputs.Requested Shares' | translate }} {{ 'labels.commons.is' | translate }} @@ -44,8 +77,194 @@ {{ 'labels.inputs.Current Price' | translate }} - + + + + + {{ 'labels.inputs.Amount' | translate }} + + {{ 'labels.inputs.Total No. of Shares' | translate }} × + {{ 'labels.inputs.Current Price' | translate }} + + + +
+
{{ 'labels.inputs.Payment Option' | translate }}
+ + Apply Collection (Cash) + Debit from Savings + +
+ + @if (paymentMode === 'CASH') { +
+

+ 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. +

+ +
+ + +
+ + @if (voucherError) { +
+ error_outline + {{ voucherError }} +
+ } + + @if (loadingVouchers) { +
+ + Loading the member's receipts… +
+ } @else if (vouchers.length === 0) { +
+ info_outline +
+ No unapplied receipt for this member. +
+ Collect the cash first from Cashier › Transaction Entry as a deposit to the Share Sundry + GL. Once it is approved, the voucher appears here. +
+
+
+ } @else { + + + + + + + + + + + + @for (v of vouchers; track v.id) { + + + + + + + } + + @if (selectedVoucherIds.length > 0) { + + + + + + + + } +
Voucher / ReceiptDateAmount
+ + {{ v.voucher_no || v.receipt_no }}{{ v.collection_date }} + {{ v.amount | number: '1.2-2' }} +
+ {{ selectedVoucherIds.length }} selected, against {{ amount | number: '1.2-2' }} + + {{ selectedVoucherTotal | number: '1.2-2' }} +
+ + + @if (selectedVoucherIds.length > 0 && voucherShortfall > 0) { +
+ error_outline + + 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. + +
+ } + } +
+ } + + @if (paymentMode === 'SAVINGS') { +
+

+ 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. +

+ + @if (checkingSavings) { +
+ + Checking the linked savings account… +
+ } @else if (savingsEligibility) { +
+ @if (savingsEligibility.savingsAccountNo) { +
+ Savings account + {{ savingsEligibility.savingsAccountNo }} +
+
+ Available balance + {{ savingsEligibility.savingsBalance | number: '1.2-2' }} +
+ } +
+ To debit + {{ amount | number: '1.2-2' }} +
+
+ + @if (!savingsEligibility.eligible) { +
+ error_outline + {{ savingsEligibility.reason }} +
+ } + } +
+ } + + @if (errorMsg) { +
+ error_outline + {{ errorMsg }} +
+ } + + +

+ 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. +

@@ -53,8 +272,8 @@ - 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 7c3f1555b..49017d355 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 @@ -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; +} 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 2a4bde0c2..751167c6d 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 @@ -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: + * + *
    + *
  • the board resolution authorising this particular tranche, which is its own meeting and its + * own number, not the one the account was opened under;
  • + *
  • where the money is coming from, because a tranche nobody has paid for cannot be approved.
  • + *
+ * + * Both funding routes converge on the Share Sundry holding account, so approval posts the same + * Dr Sundry / Cr Share Capital pair either way: + * + *
    + *
  • Cash — 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.
  • + *
  • Savings — the member's savings are debited into Sundry on submit, so their balance + * drops immediately. Approval then moves it out to share capital.
  • + *
*/ @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 + ); + } } diff --git a/src/app/shares/shares-account-actions/approve-shares-account/approve-shares-account.component.ts b/src/app/shares/shares-account-actions/approve-shares-account/approve-shares-account.component.ts index 2d5db8a6f..331634ca7 100644 --- a/src/app/shares/shares-account-actions/approve-shares-account/approve-shares-account.component.ts +++ b/src/app/shares/shares-account-actions/approve-shares-account/approve-shares-account.component.ts @@ -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: [''] diff --git a/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.html b/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.html index 02dc30eba..a7871caa2 100644 --- a/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.html +++ b/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.html @@ -26,6 +26,29 @@ {{ share.purchasedPrice }} + + {{ 'labels.inputs.Amount' | translate }} + {{ trancheAmount(share) | number: '1.2-2' }} + + + + + {{ 'labels.inputs.Resolution Number' | translate }} + + {{ share.resolutionNumber || '—' }} + @if (share.meetingDate) { + ({{ share.meetingDate | dateFormat }}) + } + + + + + + {{ 'labels.inputs.Payment Option' | translate }} + {{ fundingLabel(share) || '—' }} + + {{ 'labels.inputs.Status' | translate }} diff --git a/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.scss b/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.scss index 3c8ff780e..c0253f07f 100644 --- a/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.scss +++ b/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.scss @@ -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; + } } diff --git a/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.ts b/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.ts index c05c36151..ab79888d2 100644 --- a/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.ts +++ b/src/app/shares/shares-account-actions/approve-shares/approve-shares.component.ts @@ -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. */ diff --git a/src/app/shares/shares-account-actions/close-shares-account/close-shares-account.component.ts b/src/app/shares/shares-account-actions/close-shares-account/close-shares-account.component.ts index f897cc28f..2ab559776 100644 --- a/src/app/shares/shares-account-actions/close-shares-account/close-shares-account.component.ts +++ b/src/app/shares/shares-account-actions/close-shares-account/close-shares-account.component.ts @@ -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: [''] diff --git a/src/app/shares/shares-account-actions/redeem-shares/redeem-shares.component.ts b/src/app/shares/shares-account-actions/redeem-shares/redeem-shares.component.ts index 53b607175..3983a9db4 100644 --- a/src/app/shares/shares-account-actions/redeem-shares/redeem-shares.component.ts +++ b/src/app/shares/shares-account-actions/redeem-shares/redeem-shares.component.ts @@ -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: [ diff --git a/src/app/shares/shares-account-actions/reject-shares-account/reject-shares-account.component.ts b/src/app/shares/shares-account-actions/reject-shares-account/reject-shares-account.component.ts index 4960dcee7..5cef0f437 100644 --- a/src/app/shares/shares-account-actions/reject-shares-account/reject-shares-account.component.ts +++ b/src/app/shares/shares-account-actions/reject-shares-account/reject-shares-account.component.ts @@ -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: [''] diff --git a/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.ts b/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.ts index 89c6f9bf3..cc944caa5 100644 --- a/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.ts +++ b/src/app/shares/shares-account-stepper/shares-account-details-step/shares-account-details-step.component.ts @@ -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: [''], 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 a0f7778d3..542808e3d 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 @@ -10,17 +10,18 @@

- Enter the voucher number the member was given, or tick the receipts below, to settle against share account - {{ data.accountNo }}. 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 {{ data.accountNo }}, 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.

+
info_outline
- No unapplied receipt for this member. + No unapplied receipt for this member today.
- Collect the cash first from Cashier › 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 › Transaction Entry as a deposit to the Share Sundry GL — once a manager approves it, the + voucher appears here.
@@ -62,7 +64,7 @@ - + @@ -71,7 +73,7 @@ @for (r of receipts; track r.id) { - - + } - @if (selectedIds.length > 1) { + + @if (selectedIds.length > 0) { - - - + + }
Voucher / Receipt Date Amount
+ {{ r.voucher_no || r.receipt_no }} {{ r.collection_date }} - {{ r.amount }} - {{ r.amount | number: '1.2-2' }}
{{ selectedIds.length }} receipts selected - {{ selectedTotal | number: '1.2-2' }} + + {{ selectedIds.length }} {{ selectedIds.length === 1 ? 'receipt' : 'receipts' }} selected {{ selectedTotal | number: '1.2-2' }}
- 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.
}
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 ba7e9deeb..014f0c3c8 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 @@ -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; } } } 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 5546e11e3..a947c6ce5 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 @@ -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 = ''; }); } diff --git a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html index c21ac09b1..17523631d 100644 --- a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.html @@ -178,14 +178,16 @@ {{ txn.amount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }} - @if (isRedeem(txn)) { - {{ txn.resolutionNumber || '—' }} + + @if (txn.resolutionNumber) { + {{ txn.resolutionNumber }} @if (txn.meetingDate) { ({{ txn.meetingDate | dateFormat }}) } } @else { - {{ resolutionNumber || '—' }} @if (resolutionDate) { ({{ resolutionDate | dateFormat }}) diff --git a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts index f9cca00b8..da843b90a 100644 --- a/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts +++ b/src/app/shares/shares-account-view/passbook-tab/passbook-tab.component.ts @@ -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]; }); diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html index 790c375ad..130f27404 100644 --- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html +++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html @@ -99,6 +99,48 @@ {{ cumulativeDividend | currency: sharesAccountData.currency.code : 'symbol-narrow' }} + + + + + @if (allotments.length > 0) { + + + + + + + + + + + + @for (row of allotments; track $index) { + + + + + + + + } + +
Date of AllotmentSharesFace ValueShare NumbersResolution No. / Meeting Date
{{ row.date | dateFormat }}{{ row.shares }} + {{ row.faceValue | currency: sharesAccountData.currency.code : 'symbol-narrow' }} + + {{ row.shareNumbersFrom.toLocaleString() }} – {{ row.shareNumbersTo.toLocaleString() }} + + {{ row.resolutionNumber || '—' }} + @if (row.meetingDate) { + ({{ row.meetingDate | dateFormat }}) + } +
+ } + + +
TOTAL FACE VALUE diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss index ddfd4101c..97e7362ac 100644 --- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss +++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss @@ -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; diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts index 1f5ea5703..2b4c517b9 100644 --- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts +++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts @@ -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 [ diff --git a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html index cb819bf7e..8b133df93 100644 --- a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html +++ b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.html @@ -62,14 +62,16 @@ Resolution No. / Meeting Date - @if (transaction.type.value === 'Redeem') { - {{ transaction.resolutionNumber || '—' }} + + @if (transaction.resolutionNumber) { + {{ transaction.resolutionNumber }} @if (transaction.meetingDate) { ({{ transaction.meetingDate | dateFormat }}) } } @else { - {{ shareAccountData.resolutionNumber || '—' }} @if (shareAccountData.resolutionDate) { ({{ shareAccountData.resolutionDate | dateFormat }}) diff --git a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts index 46a7e3db0..3a6fb578d 100644 --- a/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts +++ b/src/app/shares/shares-account-view/transactions-tab/transactions-tab.component.ts @@ -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()); diff --git a/src/app/shares/shares.service.ts b/src/app/shares/shares.service.ts index 956093a20..0bf0f7d71 100644 --- a/src/app/shares/shares.service.ts +++ b/src/app/shares/shares.service.ts @@ -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} `{ eligible, reason, requestedShares, unitPrice, amount, + * savingsAccountNo, savingsBalance }`. + */ + getAdditionalSharesSavingsEligibility(accountId: string, requestedShares: number): Observable { + 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} One row per pending account. + */ + getPendingShareAccounts(): Observable { + return this.http.get('/shareaccounts/pending'); + } + /** * @param {string} accountId Shares Account Id. * @returns {Observable} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.html b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.html new file mode 100644 index 000000000..6e15d8f66 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.html @@ -0,0 +1,107 @@ + + +
+ @if (showData) { +
+ + search + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Account No.{{ account.accountNo }}Member + {{ account.clientName }} + @if (account.officeName) { +
{{ account.officeName }}
+ } +
Product{{ account.productName }}Shares{{ account.requestedShares }}Amount + {{ account.requestedAmount | number: '1.2-2' }} + Resolution + {{ account.resolutionNumber || '—' }} + @if (account.resolutionDate) { +
{{ formatDate(account.resolutionDate) }}
+ } +
Submitted + {{ formatDate(account.submittedDate) }} + @if (account.submittedByUsername) { +
by {{ account.submittedByUsername }}
+ } +
Actions + + + +
+
+ } + + @if (!showData) { +
+ +

No pending share accounts

+

There are no share accounts awaiting approval at this time.

+
+ } +
diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.scss b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.scss new file mode 100644 index 000000000..3e8e2c212 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.scss @@ -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; + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.ts new file mode 100644 index 000000000..03cf94472 --- /dev/null +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component.ts @@ -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; + 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])); + } +} diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index ffcc701fa..8e5a2f0af 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -90,6 +90,17 @@ Pending Account Edits + + + + Pending Share Accounts + { + private sharesService = inject(SharesService); + + resolve(): Observable { + return this.sharesService.getPendingShareAccounts().pipe(catchError(() => of([]))); + } +} diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index 416cf3017..7b8701aa4 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -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 {} diff --git a/src/assets/translations/en-US.json b/src/assets/translations/en-US.json index 46110091b..9160611c2 100644 --- a/src/assets/translations/en-US.json +++ b/src/assets/translations/en-US.json @@ -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", diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 6c6244804..3e9cc6755 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260825', - 'hash': '7ef75c90' + 'version': '260827', + 'hash': '1273bdbe' }, 'allow_switching_backend_instance': true };