diff --git a/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts b/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts
index 2aa1f0481..379940db2 100644
--- a/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts
+++ b/src/app/accounting/financial-activity-mappings/create-financial-activity-mapping/create-financial-activity-mapping.component.ts
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
import { GLAccount } from 'app/shared/models/general.model';
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { accountOptionsForActivity } from '../financial-activity-account-options';
/**
* Create financial activity mapping component.
@@ -87,21 +88,11 @@ export class CreateFinancialActivityMappingComponent implements OnInit {
*/
setGlAccountData() {
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
- switch (financialActivityId) {
- case 100:
- case 101:
- case 102:
- case 103:
- this.glAccountData = this.glAccountOptions.assetAccountOptions;
- break;
- case 200:
- case 201:
- this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
- break;
- case 300:
- this.glAccountData = this.glAccountOptions.equityAccountOptions;
- break;
- }
+ this.glAccountData = accountOptionsForActivity(
+ this.financialActivityData,
+ financialActivityId,
+ this.glAccountOptions
+ );
});
}
diff --git a/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts b/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts
index db1f71ca2..b8c6160b7 100644
--- a/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts
+++ b/src/app/accounting/financial-activity-mappings/edit-financial-activity-mapping/edit-financial-activity-mapping.component.ts
@@ -16,6 +16,7 @@ import { AccountingService } from '../../accounting.service';
import { GLAccount } from 'app/shared/models/general.model';
import { GlAccountSelectorComponent } from '../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { accountOptionsForActivity } from '../financial-activity-account-options';
/**
* Edit financial activity mapping component.
@@ -98,21 +99,11 @@ export class EditFinancialActivityMappingComponent implements OnInit {
*/
setGLAccountData() {
this.financialActivityMappingForm.get('financialActivityId').valueChanges.subscribe((financialActivityId) => {
- switch (financialActivityId) {
- case 100:
- case 101:
- case 102:
- case 103:
- this.glAccountData = this.glAccountOptions.assetAccountOptions;
- break;
- case 200:
- case 201:
- this.glAccountData = this.glAccountOptions.liabilityAccountOptions;
- break;
- case 300:
- this.glAccountData = this.glAccountOptions.equityAccountOptions;
- break;
- }
+ this.glAccountData = accountOptionsForActivity(
+ this.financialActivityData,
+ financialActivityId,
+ this.glAccountOptions
+ );
});
}
diff --git a/src/app/accounting/financial-activity-mappings/financial-activity-account-options.ts b/src/app/accounting/financial-activity-mappings/financial-activity-account-options.ts
new file mode 100644
index 000000000..57a076f89
--- /dev/null
+++ b/src/app/accounting/financial-activity-mappings/financial-activity-account-options.ts
@@ -0,0 +1,38 @@
+/**
+ * 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/.
+ */
+
+/**
+ * The GL accounts a financial activity may be mapped to.
+ *
+ * The activity itself says which kind of account it needs — the template returns `mappedGLAccountType` on every
+ * option — so the answer is read from the activity rather than worked out from its id. The screens used to switch on
+ * the id instead, listing the ones they knew:
+ *
+ * case 100, 101, 102, 103 → asset
+ * case 200, 201 → liability
+ * case 300 → equity
+ *
+ * Every activity added since fell through that switch and left the account dropdown empty, which is how dividendPaid
+ * (202), reserveForDividend (301), distributableProfit (302), retainedProfit (303) and dividendRoundOff (304) became
+ * unmappable — the screen offered no accounts and gave no reason. A list keyed on ids has to be extended by hand for
+ * each new activity, and is silently wrong until someone notices; asking the activity what it needs cannot fall out
+ * of step with the backend.
+ *
+ * @param activities the template's financialActivityOptions
+ * @param activityId the activity the user picked
+ * @param glAccountOptions the template's glAccountOptions, keyed assetAccountOptions / liabilityAccountOptions / …
+ */
+export function accountOptionsForActivity(activities: any[], activityId: number, glAccountOptions: any): any[] {
+ const activity = (activities || []).find((a) => a?.id === activityId);
+ const accountType = activity?.mappedGLAccountType;
+ if (!accountType || !glAccountOptions) {
+ return [];
+ }
+ // ASSET → assetAccountOptions, and so on for LIABILITY, EQUITY, INCOME and EXPENSE.
+ return glAccountOptions[`${accountType.toLowerCase()}AccountOptions`] ?? [];
+}
diff --git a/src/app/aml/aml.service.ts b/src/app/aml/aml.service.ts
index 76bbc1905..047fed18c 100644
--- a/src/app/aml/aml.service.ts
+++ b/src/app/aml/aml.service.ts
@@ -34,6 +34,17 @@ export class AmlService {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
}
+ /**
+ * Decides named rules within one alert, leaving its other rules open.
+ *
+ *
+ * The alert's own status follows from what is left undecided, so it is not sent: whitelisting five of six rules
+ * leaves the alert open on the sixth.
+ */
+ reviewAlertRules(alertId: number, ruleCodes: string[], disposition: string, notes: string): Observable {
+ return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes, ruleCodes });
+ }
+
// ═══ Recurring-alert review queue ═══
/**
@@ -141,4 +152,38 @@ export class AmlService {
getRiskCategoryHistory(clientId: number): Observable {
return this.http.get(`/clients/${clientId}/risk-category-history`);
}
+
+ // ═══ Risk-category changes (maker-checker) ═══
+ //
+ // The rating decides how often a customer is re-KYC'd and what AML thresholds apply to them, so
+ // it moves only once a checker agrees rather than on one officer's say-so.
+
+ /** @param requestedCategory 1 Low, 2 Medium, 3 High */
+ requestRiskChange(clientId: number, requestedCategory: number, reason: string): Observable {
+ return this.http.post('/aml/risk-change-requests', { clientId, requestedCategory, reason });
+ }
+
+ getRiskChangeRequests(status?: string): Observable {
+ const params = status ? new HttpParams().set('status', status) : undefined;
+ return this.http.get('/aml/risk-change-requests', { params });
+ }
+
+ approveRiskChange(requestId: number): Observable {
+ return this.http.post(`/aml/risk-change-requests/${requestId}/approve`, {});
+ }
+
+ rejectRiskChange(requestId: number, rejectionReason: string): Observable {
+ return this.http.post(`/aml/risk-change-requests/${requestId}/reject`, { rejectionReason });
+ }
+
+ /**
+ * The latest behaviour window for each customer, as the nightly job computed it.
+ *
+ * One row per customer carrying the review period's figures and the rating they suggest. The Risk Review report
+ * prefers these over its own per-alert reckoning, because a rating describes a period rather than the handful of
+ * transactions that happened to trip a rule.
+ */
+ getBehaviourWindows(): Observable {
+ return this.http.get('/risk-review/behaviour-windows');
+ }
}
diff --git a/src/app/board-resolution/bor-report.component.html b/src/app/board-resolution/bor-report.component.html
new file mode 100644
index 000000000..ec7b8bf32
--- /dev/null
+++ b/src/app/board-resolution/bor-report.component.html
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+
Board of Resolution Register — Share Capital
+
+ Every share account with the board resolution that authorised the allotment. Accounts with no resolution on
+ record sort to the top.
+
+ @if (r.resolutionNumber) {
+ {{ r.resolutionNumber }}
+ } @else {
+ Not recorded
+ }
+
+
{{ r.resolutionDate || '—' }}
+
{{ r.accountNo }}
+
{{ r.cifNumber || '—' }}
+
{{ r.clientName }}
+
{{ r.officeName || '—' }}
+
{{ r.productName || '—' }}
+
{{ r.approvedShares | number }}
+
{{ r.shareValue | number: '1.2-2' }}
+
{{ r.trancheCount }}
+
{{ statusText(r.statusEnum) }}
+
{{ r.activatedDate || '—' }}
+
+ } @empty {
+
+
No share account matches the filters.
+
+ }
+
+
+
+ }
+
diff --git a/src/app/board-resolution/bor-report.component.scss b/src/app/board-resolution/bor-report.component.scss
new file mode 100644
index 000000000..a21ca4608
--- /dev/null
+++ b/src/app/board-resolution/bor-report.component.scss
@@ -0,0 +1,313 @@
+/**
+ * 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/.
+ */
+
+.bor-root {
+ padding: 20px 24px 40px;
+ background: #f8fafc;
+ min-height: 100%;
+}
+
+.bor-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 18px;
+}
+
+.bor-head-left {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+}
+
+.bor-head-ic {
+ font-size: 26px;
+ color: #0f4f3c;
+ margin-top: 2px;
+}
+
+.bor-head-t {
+ margin: 0 0 3px;
+ font-size: 20px;
+ font-weight: 700;
+ color: #1e293b;
+}
+
+.bor-head-s {
+ margin: 0;
+ max-width: 780px;
+ font-size: 13px;
+ line-height: 1.5;
+ color: #64748b;
+}
+
+.bor-note {
+ display: flex;
+ align-items: flex-start;
+ gap: 9px;
+ padding: 11px 14px;
+ margin-bottom: 14px;
+ border: 1px solid #bfdbfe;
+ border-radius: 9px;
+ background: #eff6ff;
+ color: #1e3a8a;
+ font-size: 12.5px;
+ line-height: 1.55;
+
+ fa-icon {
+ flex: 0 0 auto;
+ margin-top: 1px;
+ }
+
+ /* An allotment with no resolution behind it is the finding, not a detail. */
+ &--warn {
+ border-color: #fde68a;
+ background: #fffbeb;
+ color: #92400e;
+
+ fa-icon {
+ color: #d97706;
+ }
+ }
+}
+
+.bor-kpis {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.bor-kpi {
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-left: 4px solid #0f4f3c;
+ border-radius: 11px;
+ padding: 12px 16px;
+
+ &--blue {
+ border-left-color: #2563eb;
+ }
+
+ &--green {
+ border-left-color: #15803d;
+ }
+
+ &--amber {
+ border-left-color: #d97706;
+ }
+}
+
+.bor-kpi-n {
+ font-size: 22px;
+ font-weight: 700;
+ color: #1e293b;
+ line-height: 1.2;
+}
+
+.bor-kpi-l {
+ font-size: 12px;
+ color: #64748b;
+ margin-top: 2px;
+}
+
+.bor-filters {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 12px;
+ padding: 14px 16px;
+ margin-bottom: 14px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 11px;
+}
+
+.bor-filter {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ label {
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #64748b;
+ }
+
+ &--grow {
+ flex: 1 1 240px;
+ }
+}
+
+.bor-inp {
+ height: 34px;
+ min-width: 170px;
+ padding: 0 10px;
+ border: 1px solid #cbd5e1;
+ border-radius: 7px;
+ font: inherit;
+ font-size: 13px;
+ color: #1e293b;
+ background: #fff;
+
+ &:focus {
+ outline: 2px solid #2563eb;
+ outline-offset: -1px;
+ }
+}
+
+.bor-check {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: 34px;
+ font-size: 12.5px;
+ color: #334155;
+ cursor: pointer;
+}
+
+.bor-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ height: 34px;
+ padding: 0 14px;
+ border: 1px solid #cbd5e1;
+ border-radius: 7px;
+ background: #fff;
+ font: inherit;
+ font-size: 12.5px;
+ font-weight: 600;
+ color: #334155;
+ cursor: pointer;
+
+ &:hover:not(:disabled) {
+ background: #f1f5f9;
+ }
+
+ &:disabled {
+ opacity: 0.55;
+ cursor: default;
+ }
+
+ &--xls {
+ border-color: #15803d;
+ background: #16a34a;
+ color: #fff;
+
+ &:hover:not(:disabled) {
+ background: #15803d;
+ }
+ }
+}
+
+.bor-table-wrap {
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 11px;
+ overflow-x: auto;
+}
+
+.bor-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12.5px;
+
+ th {
+ text-align: left;
+ padding: 10px;
+ background: #f1f5f9;
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #475569;
+ white-space: nowrap;
+ border-bottom: 1px solid #e2e8f0;
+ }
+
+ td {
+ padding: 11px 10px;
+ border-bottom: 1px solid #f1f5f9;
+ vertical-align: middle;
+ color: #334155;
+ }
+
+ .num {
+ text-align: right;
+ font-family: 'DM Mono', monospace;
+ }
+
+ .mono {
+ font-family: 'DM Mono', monospace;
+ }
+
+ .strong {
+ font-weight: 700;
+ }
+
+ tbody tr:hover {
+ background: #f8fafc;
+ }
+}
+
+/* The row an auditor is looking for, marked in the table as well as counted above it. */
+.bor-row--missing {
+ background: #fffbeb;
+}
+
+.bor-res {
+ color: #0f4f3c;
+ font-family: 'DM Mono', monospace;
+}
+
+.bor-missing {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: #fef3c7;
+ color: #92400e;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.bor-status {
+ display: inline-block;
+ padding: 2px 9px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+ background: #f1f5f9;
+ color: #475569;
+
+ &--active {
+ background: #dcfce7;
+ color: #15803d;
+ }
+
+ &--submitted,
+ &--approved {
+ background: #fef3c7;
+ color: #b45309;
+ }
+
+ &--rejected,
+ &--closed {
+ background: #fee2e2;
+ color: #b91c1c;
+ }
+}
+
+.bor-empty {
+ padding: 30px;
+ text-align: center;
+ color: #94a3b8;
+}
diff --git a/src/app/board-resolution/bor-report.component.ts b/src/app/board-resolution/bor-report.component.ts
new file mode 100644
index 000000000..776a4ad40
--- /dev/null
+++ b/src/app/board-resolution/bor-report.component.ts
@@ -0,0 +1,221 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import { exportWorkbook } from '../cashier/cashier-export';
+import { BorRegisterRow, BorReportService } from './bor-report.service';
+
+/** m_share_account.status_enum, as the register labels it. */
+const STATUS_LABEL: Record = {
+ 100: 'Submitted',
+ 200: 'Approved',
+ 300: 'Active',
+ 400: 'Rejected',
+ 500: 'Closed',
+ 600: 'Inactive'
+};
+
+/**
+ * The Board of Resolution register for share capital.
+ *
+ * A co-operative allots shares only under a board resolution, and this is the list the Registrar or
+ * an auditor asks for: every member's holding against the resolution that authorised it. The
+ * account with no resolution recorded is the point of the register, so it sorts to the top and is
+ * counted on its own rather than being left to be noticed.
+ */
+@Component({
+ selector: 'mifosx-bor-report',
+ templateUrl: './bor-report.component.html',
+ styleUrls: ['./bor-report.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FaIconComponent,
+ FormsModule
+ ]
+})
+export class BorReportComponent implements OnInit {
+ private borService = inject(BorReportService);
+
+ readonly statusLabel = STATUS_LABEL;
+
+ rows: BorRegisterRow[] = [];
+ loading = false;
+ loadError = '';
+
+ /** '' = every status. */
+ filterStatus: number | '' = '';
+ filterBranch = '';
+ filterResolution = '';
+ search = '';
+ /** Only the accounts with no resolution on file — the ones that need chasing. */
+ missingOnly = false;
+
+ exporting = false;
+
+ ngOnInit(): void {
+ this.load();
+ }
+
+ load(): void {
+ this.loading = true;
+ this.loadError = '';
+ this.borService.getRegister().subscribe({
+ next: (rows) => {
+ this.loading = false;
+ this.rows = rows;
+ },
+ error: (err: any) => {
+ this.loading = false;
+ this.rows = [];
+ this.loadError = err?.error?.defaultUserMessage || 'Could not load the BOR register.';
+ }
+ });
+ }
+
+ /** Branches present in the data, so the filter offers only what can actually be selected. */
+ get branches(): string[] {
+ return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
+ }
+
+ get statuses(): number[] {
+ return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b);
+ }
+
+ get filtered(): BorRegisterRow[] {
+ const needle = this.search.trim().toLowerCase();
+ const resolutionNeedle = this.filterResolution.trim().toLowerCase();
+ return this.rows.filter((r) => {
+ if (this.filterStatus !== '' && r.statusEnum !== this.filterStatus) {
+ return false;
+ }
+ if (this.filterBranch && r.officeName !== this.filterBranch) {
+ return false;
+ }
+ if (this.missingOnly && r.resolutionNumber) {
+ return false;
+ }
+ if (resolutionNeedle && !r.resolutionNumber.toLowerCase().includes(resolutionNeedle)) {
+ return false;
+ }
+ if (
+ needle &&
+ !(
+ r.clientName.toLowerCase().includes(needle) ||
+ r.accountNo.toLowerCase().includes(needle) ||
+ r.cifNumber.toLowerCase().includes(needle)
+ )
+ ) {
+ return false;
+ }
+ return true;
+ });
+ }
+
+ get totalShares(): number {
+ return this.filtered.reduce((s, r) => s + r.approvedShares, 0);
+ }
+
+ get totalValue(): number {
+ return this.filtered.reduce((s, r) => s + r.shareValue, 0);
+ }
+
+ /** How many resolutions the filtered holdings were authorised under. */
+ get distinctResolutions(): number {
+ return new Set(this.filtered.map((r) => r.resolutionNumber).filter(Boolean)).size;
+ }
+
+ get missingResolutionCount(): number {
+ return this.rows.filter((r) => !r.resolutionNumber).length;
+ }
+
+ statusText(statusEnum: number): string {
+ return STATUS_LABEL[statusEnum] || '—';
+ }
+
+ statusClass(statusEnum: number): string {
+ return 'bor-status bor-status--' + (STATUS_LABEL[statusEnum] || 'unknown').toLowerCase();
+ }
+
+ resetFilters(): void {
+ this.filterStatus = '';
+ this.filterBranch = '';
+ this.filterResolution = '';
+ this.search = '';
+ this.missingOnly = false;
+ }
+
+ async exportExcel(): Promise {
+ this.exporting = true;
+ try {
+ const rows = this.filtered;
+ await exportWorkbook(
+ [
+ {
+ sheetName: 'BOR Register',
+ title: 'Board of Resolution Register — Share Capital',
+ subtitle: `${rows.length} share account(s) · ${this.distinctResolutions} resolution(s) · ${this.totalShares} shares`,
+ headers: [
+ 'Resolution No.',
+ 'Resolution Date',
+ 'Share A/c No.',
+ 'CIF No.',
+ 'Member Name',
+ 'Branch',
+ 'Product',
+ 'Shares',
+ 'Unit Price',
+ 'Share Value',
+ 'Tranches',
+ 'Status',
+ 'Activated On'
+ ],
+ widths: [
+ 20,
+ 16,
+ 18,
+ 16,
+ 30,
+ 18,
+ 18,
+ 10,
+ 12,
+ 14,
+ 10,
+ 12,
+ 14
+ ],
+ rows: rows.map((r) => [
+ // Named rather than left blank, because an empty cell in a filing reads as an
+ // oversight in the export rather than a gap in the register.
+ r.resolutionNumber || 'NOT RECORDED',
+ r.resolutionDate || '—',
+ r.accountNo,
+ r.cifNumber,
+ r.clientName,
+ r.officeName,
+ r.productName,
+ r.approvedShares,
+ r.unitPrice,
+ r.shareValue,
+ r.trancheCount,
+ this.statusText(r.statusEnum),
+ r.activatedDate || '—'
+ ])
+ }
+ ],
+ 'BOR-Register'
+ );
+ } finally {
+ this.exporting = false;
+ }
+ }
+}
diff --git a/src/app/board-resolution/bor-report.service.ts b/src/app/board-resolution/bor-report.service.ts
new file mode 100644
index 000000000..b75370e8c
--- /dev/null
+++ b/src/app/board-resolution/bor-report.service.ts
@@ -0,0 +1,94 @@
+/**
+ * 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/.
+ */
+
+/** Angular Imports */
+import { Injectable, inject } from '@angular/core';
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Observable, map } from 'rxjs';
+
+/** Custom Helpers */
+import { toIsoDate } from '../core/utils/api-date';
+
+/** One share account and the board resolution that authorised it. */
+export interface BorRegisterRow {
+ shareAccountId: number;
+ accountNo: string;
+ clientId: number;
+ cifNumber: string;
+ clientName: string;
+ officeId: number | null;
+ officeName: string;
+ productName: string;
+ currencyCode: string;
+ statusEnum: number;
+ approvedShares: number;
+ pendingShares: number;
+ unitPrice: number;
+ shareValue: number;
+ resolutionNumber: string;
+ /** ISO date, or '' where no meeting date was ever recorded against the allotment. */
+ resolutionDate: string;
+ /** How many purchase tranches make up the holding. */
+ trancheCount: number;
+ submittedDate: string;
+ approvedDate: string;
+ activatedDate: string;
+ closedDate: string;
+}
+
+/**
+ * The BOR register.
+ *
+ * One call returns every account with its resolution. The share allotment report next door fetches
+ * each account separately and stops at two hundred, which is a limit on a register rather than on a
+ * page of one.
+ */
+@Injectable({ providedIn: 'root' })
+export class BorReportService {
+ private http = inject(HttpClient);
+
+ getRegister(status?: number, officeId?: number): Observable {
+ let params = new HttpParams();
+ if (status != null) {
+ params = params.set('status', String(status));
+ }
+ if (officeId != null) {
+ params = params.set('officeId', String(officeId));
+ }
+ return this.http
+ .get('/shareaccounts/bor-register', { params })
+ .pipe(map((rows) => (rows || []).map((r) => this.toRow(r))));
+ }
+
+ /** Dates arrive as [y, M, d] arrays; flattened to ISO so they sort and filter as strings. */
+ private toRow(r: any): BorRegisterRow {
+ return {
+ shareAccountId: r.shareAccountId,
+ accountNo: r.accountNo || '',
+ clientId: r.clientId,
+ cifNumber: r.cifNumber || '',
+ clientName: r.clientName || '',
+ officeId: r.officeId ?? null,
+ officeName: r.officeName || '',
+ productName: r.productName || '',
+ currencyCode: r.currencyCode || 'INR',
+ statusEnum: Number(r.statusEnum) || 0,
+ approvedShares: Number(r.approvedShares) || 0,
+ pendingShares: Number(r.pendingShares) || 0,
+ unitPrice: Number(r.unitPrice) || 0,
+ shareValue: Number(r.shareValue) || 0,
+ resolutionNumber: (r.resolutionNumber || '').trim(),
+ resolutionDate: toIsoDate(r.resolutionDate),
+ trancheCount: Number(r.trancheCount) || 0,
+ submittedDate: toIsoDate(r.submittedDate),
+ approvedDate: toIsoDate(r.approvedDate),
+ activatedDate: toIsoDate(r.activatedDate),
+ closedDate: toIsoDate(r.closedDate)
+ };
+ }
+}
diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts
index 32f24de9b..045252339 100644
--- a/src/app/cashier/cashier-routing.module.ts
+++ b/src/app/cashier/cashier-routing.module.ts
@@ -26,7 +26,6 @@ import { VaultPositionComponent } from './vault-position/vault-position.componen
import { AccountRegisterComponent } from './account-register/account-register.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
-import { ShareCollectionComponent } from './share-collection/share-collection.component';
const routes: Routes = [
Route.withShell([
@@ -345,11 +344,6 @@ const routes: Routes = [
component: ExcessTransactionReportComponent,
data: { title: 'Excess Transaction Report', breadcrumb: 'Excess Transaction Report' }
},
- {
- path: 'share-collection',
- component: ShareCollectionComponent,
- data: { title: 'Share Collection', breadcrumb: 'Share Collection' }
- },
{
path: 'transaction-entry',
canActivate: [CashierOnlyGuard],
diff --git a/src/app/cashier/cashier.module.ts b/src/app/cashier/cashier.module.ts
index 2cf93e092..ec6dcfeeb 100644
--- a/src/app/cashier/cashier.module.ts
+++ b/src/app/cashier/cashier.module.ts
@@ -20,7 +20,6 @@ import { ReconciliationComponent } from './reconciliation/reconciliation.compone
import { CashHistoryComponent } from './cash-history/cash-history.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
-import { ShareCollectionComponent } from './share-collection/share-collection.component';
@NgModule({
imports: [
@@ -35,8 +34,7 @@ import { ShareCollectionComponent } from './share-collection/share-collection.co
ReconciliationComponent,
CashHistoryComponent,
ClosingCashReportComponent,
- ExcessTransactionReportComponent,
- ShareCollectionComponent
+ ExcessTransactionReportComponent
]
})
export class CashierModule {}
diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts
index 9845ce8a4..3078701f8 100644
--- a/src/app/cashier/cashier.service.ts
+++ b/src/app/cashier/cashier.service.ts
@@ -178,6 +178,13 @@ export class CashierService {
return this.http.get('/vouchers/my-work', { params });
}
+ /** One voucher and every debit/credit line it is made of — the full slip behind a summarised
+ * row (a "+2 more" badge in a register only ever shows the first side of each). */
+ voucherDetail(voucherNo: string): Observable {
+ const params = new HttpParams().set('voucherNo', voucherNo);
+ return this.http.get('/vouchers/detail', { params });
+ }
+
/**
* Account register for one date: what opened and closed that day, each account's balance as it
* stood then, and what it holds now.
@@ -357,19 +364,6 @@ export class CashierService {
return this.http.get('/paymenttypes');
}
- // ── Share Collection ──
- getShareAccountForCollection(accountId: string): Observable {
- return this.http.get(`/accounts/share/${accountId}`);
- }
-
- collectSharePayment(data: any): Observable {
- return this.http.post('/cashier/sharecollection', data);
- }
-
- getShareCollectionStatus(shareAccountId: number): Observable {
- return this.http.get(`/cashier/sharecollection/${shareAccountId}`);
- }
-
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable {
// The voucher travels as a query parameter, not a path segment: numbers are formatted
@@ -396,10 +390,18 @@ export class CashierService {
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}` : '';
- return this.http.get(`/shareadvancecollection${suffix}`);
+ /**
+ * Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts,
+ * ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL
+ * deposits and receipts linked to another member — which attach by voucher instead.
+ *
+ * One request rather than two: both lists come from the same table under the same filters, and
+ * splitting them across endpoints only meant the same rows were fetched twice and, where they
+ * overlapped, drawn twice on the same screen.
+ */
+ getShareCollectionReceipts(clientId?: number): Observable {
+ const params = clientId ? new HttpParams().set('clientId', String(clientId)) : undefined;
+ return this.http.get('/shareadvancecollection/unattributed', { params });
}
/** Moves a receipt out of Sundry into share capital for an activated account. */
@@ -407,6 +409,12 @@ export class CashierService {
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
}
+ /** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box,
+ * which is unrelated screen state that can name a different account than this receipt sits in. */
+ getShareAdvanceGlEntries(collectionId: number): Observable {
+ return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`);
+ }
+
/** Every additional-share tranche awaiting approval, across every share account. */
getShareAdditionRequests(): Observable {
return this.http.get('/shareadditionrequests');
@@ -419,4 +427,14 @@ export class CashierService {
getShareAdditionRequest(transactionId: number): Observable {
return this.http.get(`/shareadditionrequests/${transactionId}`);
}
+
+ /**
+ * Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the
+ * "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later
+ * without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's
+ * options should still be usable for every other field.
+ */
+ getEntryTypeOptions(): Observable {
+ return this.http.get('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([])));
+ }
}
diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts
index b5876e77e..929a2af27 100644
--- a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts
+++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts
@@ -174,19 +174,24 @@ export class ClosingCashReportComponent implements OnInit {
return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0);
}
+ /**
+ * Summed from each row's own `netCount`, not re-derived from today's legs — a row's `netCount`
+ * already carries forward what a cashier was holding from before today (`openingCount`), for the
+ * force-closed-day/late-settlement case. Recomputing here from only today's vaultIn/deposit/
+ * withdrawal/settledOut dropped that carry-forward and put the Total row back into negative
+ * numbers a cashier had already reconciled.
+ */
get noteNetCount(): number {
- return this.noteVaultInCount + this.noteDepositCount - this.noteWithdrawalCount - this.noteSettledOutCount;
+ return this.noteRows.reduce((s, r) => s + Number(r.netCount ?? 0), 0);
}
/**
- * What is left in the drawer in notes: everything that came in, less everything that went out.
- *
- * Was deposits minus withdrawals, which is the counter's movement rather than the drawer's
- * contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net
- * of zero and had nothing to count against.
+ * What is left in the drawer in notes: everything that came in, less everything that went out —
+ * including cash carried forward from before today. See `noteNetCount` for why this sums each
+ * row's own `netAmount` rather than re-deriving it from today's legs.
*/
get noteNetAmount(): number {
- return this.noteVaultInAmount + this.noteDepositAmount - this.noteWithdrawalAmount - this.noteSettledOutAmount;
+ return this.noteRows.reduce((s, r) => s + Number(r.netAmount ?? 0), 0);
}
get coinRows(): any[] {
return this.rows.filter((r) => r.kind === 'coin');
diff --git a/src/app/cashier/my-work/my-work.component.html b/src/app/cashier/my-work/my-work.component.html
index 50139959d..e4c96e7db 100644
--- a/src/app/cashier/my-work/my-work.component.html
+++ b/src/app/cashier/my-work/my-work.component.html
@@ -152,9 +152,21 @@
@for (r of g.rows; track r.voucherNo) {
-
-
{{ r.voucherNo }}
+ is when the heading has scrolled away. Clicking anywhere on the row opens the full
+ slip -- every line, not just the first debit and first credit this table has room
+ to show. -->
+
diff --git a/src/app/cashier/my-work/my-work.component.scss b/src/app/cashier/my-work/my-work.component.scss
index 674708b5e..4e8d33e25 100644
--- a/src/app/cashier/my-work/my-work.component.scss
+++ b/src/app/cashier/my-work/my-work.component.scss
@@ -178,6 +178,21 @@
}
}
+.mw-row--clickable {
+ cursor: pointer;
+
+ &:hover {
+ background: #f8fafc;
+ }
+}
+
+.mw-vno__spinner {
+ margin-left: 4px;
+ font-size: 14px;
+ vertical-align: middle;
+ color: #94a3b8;
+}
+
/* One section per part played. The heading carries the section's own count and total, so a maker's
tally and a checker's are each readable without adding rows up. */
.mw-group {
diff --git a/src/app/cashier/my-work/my-work.component.ts b/src/app/cashier/my-work/my-work.component.ts
index 9173fc55f..4dbee818f 100644
--- a/src/app/cashier/my-work/my-work.component.ts
+++ b/src/app/cashier/my-work/my-work.component.ts
@@ -10,8 +10,10 @@ import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
-import { catchError, of } from 'rxjs';
+import { MatDialog, MatDialogModule } from '@angular/material/dialog';
+import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
+import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -72,11 +74,17 @@ interface MyWorkReport {
imports: [
CommonModule,
FormsModule,
- MatIconModule
+ MatIconModule,
+ MatDialogModule
]
})
export class MyWorkComponent implements OnInit {
private service = inject(CashierService);
+ private dialog = inject(MatDialog);
+
+ /** Which voucher's detail is currently being fetched, so the row that was clicked can show its
+ * own spinner instead of a page-wide one for what is a small, single-row lookup. */
+ loadingDetailFor: string | null = null;
/** Day is the working default; a month is what a close or an audit asks for. */
period: 'DAY' | 'MONTH' = 'DAY';
@@ -323,6 +331,31 @@ export class MyWorkComponent implements OnInit {
return { from: first, to: lastIso };
}
+ /** Opens the full slip for one voucher — every debit and credit line, not just the first side of
+ * each a table row has room to show. */
+ openDetail(voucherNo: string): void {
+ if (this.loadingDetailFor) {
+ return;
+ }
+ this.loadingDetailFor = voucherNo;
+ this.service
+ .voucherDetail(voucherNo)
+ .pipe(
+ catchError(() => of(null)),
+ finalize(() => (this.loadingDetailFor = null))
+ )
+ .subscribe((detail: any) => {
+ if (!detail) {
+ return;
+ }
+ this.dialog.open(VoucherDetailDialogComponent, {
+ data: { detail },
+ width: '600px',
+ autoFocus: false
+ });
+ });
+ }
+
private todayIso(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
diff --git a/src/app/cashier/my-work/voucher-detail-dialog.component.ts b/src/app/cashier/my-work/voucher-detail-dialog.component.ts
new file mode 100644
index 000000000..93137c3c7
--- /dev/null
+++ b/src/app/cashier/my-work/voucher-detail-dialog.component.ts
@@ -0,0 +1,320 @@
+/**
+ * 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 { CommonModule } from '@angular/common';
+import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+
+export interface VoucherDetailDialogData {
+ /** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
+ * only renders, so one loading state lives in the row's own click handler rather than being
+ * duplicated inside every dialog that could ever show a voucher. */
+ detail: {
+ voucherNo: string;
+ voucherType: string | null;
+ amount: number | null;
+ branchName: string | null;
+ createdBy: string | null;
+ createdAt: string | null;
+ totalDebit: number;
+ totalCredit: number;
+ balanced: boolean;
+ lines: {
+ lineNo: number;
+ entryType: 'DR' | 'CR';
+ partyName: string | null;
+ accountNo: string | null;
+ amount: number;
+ description: string | null;
+ }[];
+ /** GL entries a native platform command posted for the same underlying event, outside this
+ * voucher entirely — a share redemption's Dr Share Capital / Cr Share Sundry pair, posted at
+ * manager approval, before this voucher's own Dr Sundry / Cr Cash at Vault settlement leg even
+ * exists. Empty for every voucher that already is the whole transaction on its own. */
+ relatedLines: {
+ stage: string;
+ entryType: 'DR' | 'CR';
+ partyName: string | null;
+ accountNo: string | null;
+ amount: number;
+ description: string | null;
+ }[];
+ };
+}
+
+/** The full slip behind a summarised register row — every debit and credit line a voucher is
+ * made of, not just the first side of each that a table row has room for. */
+@Component({
+ selector: 'mifosx-voucher-detail-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ MatDialogModule,
+ MatButtonModule,
+ MatIconModule
+ ],
+ template: `
+
+ }
+ @if (isGlFunded) {
+ one journal entry against this GL account, not {{ lineCount }}.
+ } @else {
+ the member's passbook shows one entry, not {{ lineCount }}.
+ }
+ } @else {
+ ₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
+ approves. No cash is taken at the counter.
+ }
}
-
+ } @else {
+
+
+ }
+ @if (isSlip) {
+
+ {{ lineCount }} drafts · ₹{{ slipTotal | number: '1.2-2' }} to debit
+
+ }
-
+
`,
@@ -393,6 +598,81 @@ export interface CashierIssueDdDialogData {
line-height: 1.5;
color: #64748b;
}
+ /* One card per draft on a slip — the boundary is what stops a clerk typing line 2's
+ beneficiary against line 1's amount. */
+ .dd-line-card {
+ border: 1px solid #e2e8f0;
+ border-left: 3px solid #0369a1;
+ border-radius: 8px;
+ padding: 14px 16px 4px;
+ margin-bottom: 14px;
+ background: #fcfdff;
+ }
+ .dd-line-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-bottom: 12px;
+ }
+ .dd-line-badge {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: #ffffff;
+ background: #0369a1;
+ padding: 3px 9px;
+ border-radius: 999px;
+ }
+ .dd-line-number {
+ font-family: monospace;
+ font-size: 12px;
+ color: #475569;
+ }
+ .dd-line-number em {
+ font-style: normal;
+ color: #94a3b8;
+ font-size: 11px;
+ }
+ .dd-line-total {
+ margin-left: auto;
+ font-size: 12px;
+ font-weight: 600;
+ color: #065f46;
+ background: #ecfdf5;
+ border: 1px solid #a7f3d0;
+ padding: 3px 10px;
+ border-radius: 6px;
+ }
+ .dd-preview-note {
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+ margin: 0 0 16px;
+ padding: 9px 12px;
+ background: #fffbeb;
+ border: 1px solid #fde68a;
+ border-radius: 6px;
+ font-size: 12px;
+ line-height: 1.5;
+ color: #78350f;
+ }
+ .dd-preview-note mat-icon {
+ color: #b45309;
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+ }
+ .dd-actions-total {
+ margin-right: auto;
+ font-size: 13px;
+ color: #475569;
+ }
+ .dd-actions-total strong {
+ color: #065f46;
+ }
`
]
})
@@ -402,13 +682,127 @@ export class CashierIssueDdDialogComponent implements OnInit {
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
form!: FormGroup;
+ /** Built only in slip mode; `form` stays untouched so the single-draft path is unchanged. */
+ slipForm!: FormGroup;
/** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */
get isAccountDebit(): boolean {
return this.data.paymentMethod === 'ACCOUNT_DEBIT';
}
+ /** How many drafts this dialog is capturing — 1 unless the clerk asked for a slip. */
+ get lineCount(): number {
+ const n = Number(this.data.ddCount) || 1;
+ return n > 1 ? n : 1;
+ }
+
+ /** Whether this dialog is capturing a multi-DD slip rather than a single draft. */
+ get isSlip(): boolean {
+ return this.lineCount > 1;
+ }
+
+ /** Whether the debit lands on a GL account rather than a member's — wording only. */
+ get isGlFunded(): boolean {
+ return !!this.data.glFunded;
+ }
+
+ /** The form the Save button validates against — whichever of the two was built. */
+ get activeForm(): FormGroup {
+ return this.isSlip ? this.slipForm : this.form;
+ }
+
+ get slipLines(): FormArray {
+ return this.slipForm.get('lines') as FormArray;
+ }
+
+ /** The provisional leaflet number shown against a line, if the caller supplied a run of them. */
+ previewNumberAt(index: number): string | null {
+ return this.data.previewDdNumbers?.[index] ?? null;
+ }
+
+ private lineValueAt(index: number, field: string): number {
+ return Number(this.slipLines.at(index)?.get(field)?.value) || 0;
+ }
+
+ /**
+ * GST on one line's charge.
+ *
+ * Rounded per line, matching the server, which computes and stores GST against each draft rather
+ * than once for the slip. Summing rounded lines and rounding a summed charge can differ by a
+ * paisa, and it is the per-line figure that ends up on the draft.
+ */
+ lineGstAt(index: number): number {
+ const charges = this.lineValueAt(index, 'chargesAmount');
+ if (charges <= 0) return 0;
+ return Math.round(charges * (Number(this.data.gstPercentage) || 0)) / 100;
+ }
+
+ /** What one line adds to the debit — its face value, its charge and the GST on that charge. */
+ lineTotalAt(index: number): number {
+ return this.lineValueAt(index, 'faceValue') + this.lineValueAt(index, 'chargesAmount') + this.lineGstAt(index);
+ }
+
+ get slipFaceValue(): number {
+ return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'faceValue'), 0);
+ }
+
+ get slipCharges(): number {
+ return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'chargesAmount'), 0);
+ }
+
+ get slipGst(): number {
+ return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineGstAt(i), 0);
+ }
+
+ /** The figure the account is debited for — rounded up to the whole rupee, as the single flow does. */
+ get slipTotal(): number {
+ return Math.ceil(this.slipFaceValue + this.slipCharges + this.slipGst - 1e-9);
+ }
+
ngOnInit(): void {
+ if (this.isSlip) {
+ this.slipForm = this.fb.group({
+ lines: this.fb.array(
+ Array.from({ length: this.lineCount }, () =>
+ this.fb.group({
+ beneficiaryName: [
+ '',
+ [
+ Validators.required,
+ Validators.maxLength(120)
+ ]
+ ],
+ payableAtBranch: [
+ '',
+ [Validators.required]
+ ],
+ payableAtCity: [
+ '',
+ [Validators.required]
+ ],
+ // Zero is rejected rather than merely warned about: the server refuses a line with a
+ // face value at or below zero, and a slip that fails there has already cost the clerk
+ // every other line they typed.
+ faceValue: [
+ null as
+ | number
+ | null,
+ [
+ Validators.required,
+ Validators.min(0.01)
+ ]
+ ],
+ chargesAmount: [
+ 0,
+ [Validators.min(0)]
+ ]
+ })
+ )
+ )
+ });
+ return;
+ }
+
// Walk-in identity is required for cash and rejected outright for an account debit — the server
// refuses a walk-in name alongside an account — so the controls are only built when they apply.
// Registering them unconditionally and merely hiding them would hold the form invalid on a
@@ -475,7 +869,25 @@ export class CashierIssueDdDialogComponent implements OnInit {
}
onSubmit(): void {
- if (this.form.invalid) return;
+ if (this.activeForm.invalid) return;
+
+ // A slip closes with its lines. There is no walk-in branch here: a slip is only ever offered on
+ // a member-account debit, where the purchaser is the account holder already on record.
+ if (this.isSlip) {
+ const lines: DdSlipLineResult[] = this.slipLines.controls.map((c) => {
+ const v = c.value;
+ return {
+ beneficiaryName: (v.beneficiaryName || '').trim().toUpperCase(),
+ payableAtBranch: (v.payableAtBranch || '').trim().toUpperCase(),
+ payableAtCity: (v.payableAtCity || '').trim().toUpperCase(),
+ faceValue: Number(v.faceValue) || 0,
+ chargesAmount: Number(v.chargesAmount) || 0
+ };
+ });
+ this.dialogRef.close({ lines });
+ return;
+ }
+
const val = this.form.value;
const beneficiary = {
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html
index de55bc0f0..586117016 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.html
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.html
@@ -166,23 +166,50 @@
@@ -1756,13 +1933,28 @@
[disabled]="saving"
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
[title]="
- isDdAccountDebitRow(row)
- ? 'Auto-selects next available leaflet and opens the beneficiary form'
- : 'Auto-selects next available leaflet and opens walk-in purchaser form'
+ isMultiDdRow(row)
+ ? 'Opens one beneficiary form per draft; DD numbers are allocated on submission'
+ : isDdAccountDebitRow(row)
+ ? 'Auto-selects next available leaflet and opens the beneficiary form'
+ : 'Auto-selects next available leaflet and opens walk-in purchaser form'
"
>
- post_add
- Fill DD Details
+ {{
+ row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add'
+ }}
+
+ {{
+ row.ddLines?.length
+ ? row.ddLines?.length + ' Drafts Captured · Edit Slip'
+ : row.ddDetails
+ ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details'
+ : isMultiDdRow(row)
+ ? 'Fill ' + ddCountFor(row) + ' DD Details'
+ : 'Fill DD Details'
+ }}
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.scss b/src/app/cashier/transaction-entry/transaction-entry.component.scss
index 36cd56460..06858c598 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.scss
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.scss
@@ -916,6 +916,20 @@ input.ws-f__ctl--readonly {
font-size: 11px;
}
+/* AC.TP shown as a locked label instead of the normal editable select, while the row is filled
+ from a token-driven request whose real account type row.acTyp cannot show (see
+ acTypLockedByToken in the component). */
+span.ws-f__ctl--locked {
+ display: inline-flex;
+ align-items: center;
+ background: #f1f5f9;
+ color: #475569;
+ cursor: not-allowed;
+ user-select: none;
+ font-size: 11px;
+ font-weight: 600;
+}
+
input.ws-amt-input--locked {
background: #fef9c3;
color: #713f12;
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts
index 2ea622557..aa4c8ca6f 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.ts
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts
@@ -53,6 +53,19 @@ interface TxnRow {
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
ddCharges: number | null;
sponsorBankId?: number | null;
+ /**
+ * What a GL credit is actually for — the id of a code value under "GLEntryType" (General, Share
+ * Collection, DD Issue, or whatever an admin has added under Admin > System > Manage Codes).
+ *
+ * A cashier Receipt (or a clerk Collection) into a GL account is the counter picking, by hand,
+ * any account to post cash into — deliberately, so a broken product mapping can never block a
+ * genuine voucher. That freedom is also the hole: two unrelated flows can land in the same GL
+ * account with nothing on the row itself saying which one the money was collected for, and a
+ * manager applying a share request can pick up a voucher that was really meant to fund a DD. This
+ * tag is what a day-end reconciliation groups by, and what Share Advance Collection / Sponsor DD
+ * issuance check before adopting a row — only shown/sent for AC.TP=GL, DR./CR.=Cr rows.
+ */
+ entryTypeCvId?: number | null;
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
resolutionNumber?: string;
@@ -78,6 +91,40 @@ interface TxnRow {
fundingAccountId?: number;
fundingClientId?: number;
};
+ /**
+ * How many drafts this one row raises. 1 (or unset) is the ordinary single draft; 2 or more make
+ * the row a multi-DD slip — one debit funding several drafts, each to its own beneficiary.
+ *
+ * Typed by the clerk BEFORE opening the dialog, so the dialog knows how many beneficiary forms to
+ * present. Only a member-account debit can be a slip (see canRowBeMultiDd()).
+ */
+ ddCount?: number | null;
+ /**
+ * The per-draft lines of a multi-DD slip, captured in the dialog and held here until Save — the
+ * same "fill now, commit on Save" contract as ddDetails above.
+ *
+ * Each line carries its OWN face value and charges: a slip's drafts are routinely for different
+ * amounts to different beneficiaries, and the charge is per draft rather than per slip. That is
+ * why the row's own AMOUNT and CHARGES fields are locked and derived from these lines while a
+ * slip is being built — a clerk-typed row total could disagree with the lines, and the account
+ * would then be debited for a figure the drafts don't add up to.
+ */
+ ddLines?: DdSlipLine[];
+ /** The inventory the slip's leaflets are drawn from. The batch endpoint allocates by inventory
+ * rather than by leaflet, since the numbers are only picked inside its own locked transaction. */
+ ddInventoryId?: number;
+ /** Resolved funding account for a slip, captured alongside the lines for the same reason. */
+ ddFundingAccountId?: number;
+ ddFundingClientId?: number;
+}
+
+/** One draft on a multi-DD slip — its own beneficiary, its own face value, its own charges. */
+interface DdSlipLine {
+ beneficiaryName: string;
+ payableAtBranch: string;
+ payableAtCity: string;
+ faceValue: number;
+ chargesAmount: number;
}
interface Denomination {
@@ -1703,15 +1750,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/**
* Account types a clerk may fund a demand draft from.
*
- * A clerk raises a draft against a member's own balance, so the only sensible sources are the
- * accounts a member actually holds money in. GL was offered here too, and picking it quietly
- * switched the row onto the cash path — the one that asks for a walk-in purchaser's name and a
- * note-and-coin count, neither of which a clerk debiting an account has. The option is removed
- * rather than validated afterwards, so the wrong path cannot be reached at all.
+ * A clerk raises a draft against a member's own balance (SD/CA) or, now, against a GL account
+ * directly for an institutional/holding-account draft (see isDdAccountDebitRow()) — both are
+ * governed, maker-checker submissions. GL used to be offered here too in a different sense: picking
+ * it quietly switched the row onto the CASHIER's walk-in cash path instead (the one that asks for a
+ * purchaser's name and a note-and-coin count), which isDdAccountDebitRow() now disambiguates by
+ * role — GL means "debit this GL account" for a clerk, and still means "cash/GL purchase" for a
+ * cashier, who never reaches this list at all (isDdAccountDebitRow() is clerk-only).
*/
private static readonly DD_CLERK_ACTYPS = [
'SD',
- 'CA'
+ 'CA',
+ 'GL'
];
/**
@@ -1726,6 +1776,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
return this.acTypOptions;
}
+
+ /** True while the selected row is filled from a token-driven request whose row.acTyp is a
+ * technical stand-in rather than the account's real type — a dividend claim or an account closure
+ * both set row.acTyp = 'SD' purely to route past the SHR-specific guards elsewhere on this screen
+ * (see onTokNoBlur()), not because the counter is paying against a savings account. The AC.TP
+ * dropdown must not be left editable there: it already shows the wrong value, and a cashier
+ * changing it further would desync the row from the request it was auto-filled from. A plain
+ * withdrawal-slip token is deliberately excluded — its row.acTyp is resolved to the account's real
+ * type there, so the normal editable control already shows it correctly and there's nothing to
+ * lock. */
+ get acTypLockedByToken(): boolean {
+ return !!(this.activeDividendClaimRequest || this.activeClosureRequest);
+ }
+
+ /** What to show in place of the AC.TP dropdown while it's locked — the same label the account
+ * summary strip above already uses for each of these token pools, so the two never contradict
+ * each other on screen. */
+ get acTypLockedLabel(): string {
+ if (this.activeClosureRequest) {
+ return 'Closure';
+ }
+ if (this.activeDividendClaimRequest) {
+ return 'Dividend';
+ }
+ return '';
+ }
+
dbCrOptions = [
'Dr',
'Cr'
@@ -1834,6 +1911,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.loadCashVaultGl();
this.loadDdGstPercentage();
this.loadSponsorBanks();
+ this.cashierService
+ .getEntryTypeOptions()
+ .pipe(catchError(() => of([])))
+ .subscribe((options: any[]) => {
+ this.entryTypeOptions = options ?? [];
+ });
if (this.reviewMode) {
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
@@ -2321,14 +2404,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
/**
- * TYPE=DD funded by debiting a member's savings/current account rather than by cash.
+ * TYPE=DD funded by debiting a member's savings/current account, or a GL account directly, rather
+ * than by cash.
*
- * Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account debit
- * would let a draft be raised against an account with no cash to reconcile it against — and the
- * clerk flow is the one that carries a maker-checker step over the member's balance.
+ * Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account/GL debit
+ * would let a draft be raised with no cash to reconcile it against — and the clerk flow is the one
+ * that carries a maker-checker step over the money moved. AC.TP = GL means something different for
+ * a cashier's own DD row (a walk-in cash/GL purchase, see isDdCashGlRow()) — this method is checked
+ * first there specifically so a clerk's GL row is claimed here instead.
*/
isDdAccountDebitRow(row: TxnRow): boolean {
- return this.isClerk && row.typ === 'DD' && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB');
+ return (
+ this.isClerk &&
+ row.typ === 'DD' &&
+ (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')
+ );
}
/**
@@ -2342,19 +2432,176 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row);
}
- /** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
+ /**
+ * Whether this row needs an Entry Type tag — a credit into a GL account, which is exactly the
+ * case where the money's real purpose isn't otherwise recorded anywhere (see TxnRow.entryTypeCvId).
+ * A debit out of a GL account, or any entry against a savings/current/share account, carries no
+ * such ambiguity — the account itself says what it's for.
+ */
+ showsEntryType(row: TxnRow): boolean {
+ return row.acTyp === 'GL' && row.dbCr === 'Cr';
+ }
+
+ /**
+ * Entry Type choices for a GL credit — the "GLEntryType" code (Admin > System > Manage Codes),
+ * fetched once in ngOnInit(). A code value, not a free-text field, so the day-end "group by type"
+ * this exists for stays reliable; an admin extends the list there, not in this file.
+ */
+ entryTypeOptions: { id: number; name: string }[] = [];
+
+ /**
+ * Whether this row may raise more than one draft off a single debit.
+ *
+ * Any funding source the clerk debits: a member's savings/current account, or a GL account for an
+ * institutional slip. Cash is the exception the batch endpoint still refuses, and for a real
+ * reason rather than an arbitrary one — a slip is funded by one payment, and one counted pile of
+ * notes cannot be apportioned across drafts without the denomination total ceasing to prove what
+ * was actually taken at the counter.
+ */
+ canRowBeMultiDd(row: TxnRow): boolean {
+ return this.isDdAccountDebitRow(row);
+ }
+
+ /** Whether the clerk has asked this row for more than one draft. */
+ isMultiDdRow(row: TxnRow): boolean {
+ return this.canRowBeMultiDd(row) && (Number(row.ddCount) || 1) > 1;
+ }
+
+ /** How many drafts this row raises — always at least 1. */
+ ddCountFor(row: TxnRow): number {
+ const n = Number(row.ddCount) || 1;
+ return n > 1 ? n : 1;
+ }
+
+ /**
+ * Clamps a typed DD count into range and discards lines that no longer fit.
+ *
+ * Lowering the count after the dialog has been filled would otherwise leave lines beyond the new
+ * count still on the row: invisible to the clerk, but still sent at Save and still debited for.
+ */
+ onDdCountChange(row: TxnRow, value: number): void {
+ const n = Math.floor(Number(value) || 1);
+ const previous = this.ddCountFor(row);
+ row.ddCount = n < 1 ? 1 : n > this.maxDdSlipLines ? this.maxDdSlipLines : n;
+ if (row.ddLines && row.ddLines.length !== row.ddCount) {
+ this.clearDdCapture(row);
+ }
+ // Moving onto a slip empties the row's own amount and charge, because on a slip they mean
+ // nothing: each draft carries its own face value and its own charge, entered in the dialog, and
+ // these two fields become the total of those. Leaving a previously typed figure sitting here
+ // would read as the slip's total while describing only one draft — which is exactly what showed
+ // ₹1,012 against four drafts of ₹1,000.
+ if (row.ddCount > 1 && previous <= 1) {
+ row.amount = null;
+ row.ddCharges = null;
+ }
+ }
+
+ /** Mirrors MAX_SLIP_LINES on the server, so an over-long slip is refused here with a clear message. */
+ readonly maxDdSlipLines = 20;
+
+ /**
+ * The run of leaflet numbers a slip will probably draw, for display only.
+ *
+ * Counts up from the next-available number, preserving its width so 000123 reads as 000124 rather
+ * than 124. Indicative by nature — the binding allocation happens inside the server's submit
+ * transaction, and a slip filed after another clerk's gets the numbers following theirs — which is
+ * why the dialog labels these provisional and the confirmed numbers come back on submission.
+ *
+ * A number that is not a plain run of digits (a prefixed or hyphenated series) cannot be counted
+ * on safely, so nothing is shown rather than something wrong.
+ */
+ private previewDdNumbersFrom(firstDdNumber: string, count: number): string[] | undefined {
+ const first = (firstDdNumber || '').trim();
+ if (!/^\d+$/.test(first)) return undefined;
+ const width = first.length;
+ const start = Number(first);
+ if (!Number.isSafeInteger(start)) return undefined;
+ return Array.from({ length: count }, (_v, i) => String(start + i).padStart(width, '0'));
+ }
+
+ /** GST on a single charge figure, at the admin-configured rate. */
+ private ddGstOn(charges: number): number {
+ const c = Number(charges) || 0;
+ if (c <= 0) return 0;
+ return Math.round(c * this.ddGstPercentage) / 100;
+ }
+
+ /**
+ * GST on DD charges only (never on the DD face value), at the admin-configured rate.
+ *
+ * On a slip this is the sum of each line's own GST rather than GST on the summed charges. The two
+ * can differ by a paisa once rounding is applied per line, and it is the per-line figure that is
+ * stored against each draft and posted by the server — so the row must show that same total.
+ */
ddGstAmountFor(row: TxnRow): number {
- const charges = Number(row.ddCharges) || 0;
- if (charges <= 0) return 0;
- return Math.round(charges * this.ddGstPercentage) / 100;
+ if (row.ddLines?.length) {
+ return row.ddLines.reduce((sum, l) => sum + this.ddGstOn(l.chargesAmount), 0);
+ }
+ return this.ddGstOn(row.ddCharges ?? 0);
+ }
+
+ /** Face value of the row — the sum of every line once a slip has been captured. */
+ ddFaceValueFor(row: TxnRow): number {
+ if (row.ddLines?.length) {
+ return row.ddLines.reduce((sum, l) => sum + (Number(l.faceValue) || 0), 0);
+ }
+ return Number(row.amount) || 0;
+ }
+
+ /** Charges on the row — the sum of every line's own charge once a slip has been captured. */
+ ddChargesFor(row: TxnRow): number {
+ if (row.ddLines?.length) {
+ return row.ddLines.reduce((sum, l) => sum + (Number(l.chargesAmount) || 0), 0);
+ }
+ return Number(row.ddCharges) || 0;
}
/** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */
ddTotalPayableFor(row: TxnRow): number {
- const amount = Number(row.amount) || 0;
- const charges = Number(row.ddCharges) || 0;
- const gst = this.ddGstAmountFor(row);
- return Math.ceil(amount + charges + gst - 1e-9);
+ return Math.ceil(this.ddFaceValueFor(row) + this.ddChargesFor(row) + this.ddGstAmountFor(row) - 1e-9);
+ }
+
+ /**
+ * What the TOTAL field shows — a figure, or a dash while a slip has nothing to total yet.
+ *
+ * A slip whose drafts have not been entered has no total: its face values and charges live on the
+ * lines, and there are no lines. Showing ₹0 there would read as a real total of zero rather than
+ * as "not yet known", so the field says so instead.
+ */
+ ddTotalDisplayFor(row: TxnRow): string {
+ if (this.isSlipAwaitingDetails(row)) {
+ return '—';
+ }
+ return String(this.ddTotalPayableFor(row));
+ }
+
+ /** GST field's text, dashed for the same reason as the total while a slip is still empty. */
+ ddGstDisplayFor(row: TxnRow): string {
+ if (this.isSlipAwaitingDetails(row)) {
+ return '—';
+ }
+ return this.ddGstAmountFor(row).toFixed(2);
+ }
+
+ /** A slip the clerk has asked for but not yet filled in — nothing to total from. */
+ isSlipAwaitingDetails(row: TxnRow): boolean {
+ return this.isMultiDdRow(row) && !row.ddLines?.length;
+ }
+
+ /**
+ * Forgets a row's captured draft details, leaving the row itself intact.
+ *
+ * Both shapes are cleared together on purpose: a row carrying a stale ddDetails alongside fresh
+ * ddLines (or the reverse) would be read by whichever of the two Save paths it reached first, and
+ * the clerk would have no way of seeing which one went.
+ */
+ private clearDdCapture(row: TxnRow): void {
+ row.ddDetails = undefined;
+ row.ddLines = undefined;
+ row.ddInventoryId = undefined;
+ row.ddFundingAccountId = undefined;
+ row.ddFundingClientId = undefined;
}
get isSelectedRowDdCashGl(): boolean {
@@ -2395,11 +2642,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
]);
return;
}
+ // On a slip the face value lives on each line, typed in the dialog that is about to open — the
+ // row's own AMOUNT is the sum of those lines and cannot exist yet. Demanding it here would make
+ // a slip impossible to start.
+ const isSlip = this.isMultiDdRow(row);
const faceValue = Number(row.amount);
- if (!faceValue || faceValue <= 0) {
+ if (!isSlip && (!faceValue || faceValue <= 0)) {
this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']);
return;
}
+ if (isSlip && !this.isDdAccountDebitRow(row)) {
+ this.showAlert('error', 'Multiple DDs Not Available', [
+ 'More than one draft can only be raised against a member savings or current account.',
+ 'Set NO. OF DD back to 1 for this row.'
+ ]);
+ return;
+ }
const chargesAmount = Number(row.ddCharges) || 0;
const gstAmount = this.ddGstAmountFor(row);
const totalCashPayable = this.ddTotalPayableFor(row);
@@ -2408,19 +2666,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// An account-funded draft needs the account resolved to a Fineract id before it can be sent:
// the server takes a savings/current account id and a client id, not the typed number. Both are
// filled in by the A/C NO. lookup, so an unresolved account means the number was never found.
+ // A GL-funded draft is the exception: a GL account is never tied to a client at all (see
+ // fetchAllDetails() — GL rows never populate rowClientIds), so only the account id is required.
+ const isGlDebit = accountDebit && row.acTyp === 'GL';
let fundingAccountId: number | null = null;
let fundingClientId: number | null = null;
if (accountDebit) {
const acNo = (row.acNo || '').trim();
if (!acNo) {
this.showAlert('error', 'Account Required', [
- 'Enter the account number the Demand Draft is funded from.'
+ isGlDebit
+ ? 'Enter the GL account the Demand Draft is funded from.'
+ : 'Enter the account number the Demand Draft is funded from.'
]);
return;
}
fundingAccountId = this.rowAccountIds[acNo] ?? null;
- fundingClientId = this.rowClientIds[acNo] ?? null;
- if (!fundingAccountId || !fundingClientId) {
+ fundingClientId = isGlDebit ? null : (this.rowClientIds[acNo] ?? null);
+ if (!fundingAccountId || (!isGlDebit && !fundingClientId)) {
this.showAlert('error', 'Account Not Found', [
`Account ${acNo} could not be resolved.`,
'Check the account number and wait for its details to load before raising the draft.'
@@ -2430,8 +2693,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
// Only a cash draft has notes to reconcile. An account debit takes no cash at the counter, so a
- // denomination count left over from an earlier row must not be checked against it.
- if (!accountDebit && this.denominationTotal > 0 && Math.abs(this.denominationTotal - totalCashPayable) > 0.005) {
+ // denomination count left over from an earlier row must not be checked against it. A slip is
+ // always an account debit, and its total is not known until the dialog is filled in either.
+ if (
+ !accountDebit &&
+ !isSlip &&
+ this.denominationTotal > 0 &&
+ Math.abs(this.denominationTotal - totalCashPayable) > 0.005
+ ) {
this.showAlert('error', 'Denomination Mismatch', [
`Cash denomination counted (₹${this.denominationTotal}) does not match total payable cash (₹${totalCashPayable}).`,
'Please verify and match the physical cash denomination count.'
@@ -2454,8 +2723,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const selectedBank = this.sponsorBanks.find((b: any) => b.id === row.sponsorBankId);
const branchName = this.branchNameFor(row.br) || 'Branch ' + row.br;
+ // A slip is allocated by inventory, not leaflet: the server picks the whole run inside its
+ // own locked transaction. Without the inventory behind this branch's next-available leaflet
+ // there is nothing to allocate from, so the slip is refused here rather than at submit.
+ if (isSlip && !leaflet.inventoryId) {
+ this.showAlert('error', 'DD Inventory Not Identified', [
+ 'The DD inventory behind this branch and sponsor bank could not be identified, so multiple drafts cannot be allocated together.',
+ 'Set NO. OF DD back to 1, or ask an administrator to check the DD inventory registration.'
+ ]);
+ return;
+ }
+
const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, {
- width: '600px',
+ width: isSlip ? '760px' : '600px',
disableClose: true,
data: {
leaflet,
@@ -2468,7 +2748,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
totalCashPayable,
paymentMethod: accountDebit ? ('ACCOUNT_DEBIT' as const) : ('CASH' as const),
accountNo: accountDebit ? row.acNo : undefined,
- accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined
+ accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined,
+ ddCount: isSlip ? this.ddCountFor(row) : undefined,
+ glFunded: isGlDebit,
+ previewDdNumbers: isSlip ? this.previewDdNumbersFrom(leaflet.ddNumber, this.ddCountFor(row)) : undefined
}
});
@@ -2477,6 +2760,31 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// review the entered details, and no way to abandon them either.
dialogRef.afterClosed().subscribe((result) => {
if (!result) return;
+
+ // A slip records its lines instead of a single beneficiary, and takes the row's AMOUNT and
+ // CHARGES with it: from here on those two are the sum of the lines, shown read-only, so a
+ // clerk cannot leave a row total behind that disagrees with what the drafts add up to.
+ if (result.lines) {
+ this.clearDdCapture(row);
+ row.ddLines = result.lines;
+ row.ddInventoryId = leaflet.inventoryId;
+ row.ddFundingAccountId = fundingAccountId!;
+ // Null on a GL slip, which is raised against no member at all.
+ row.ddFundingClientId = fundingClientId ?? undefined;
+ row.amount = this.ddFaceValueFor(row);
+ row.ddCharges = this.ddChargesFor(row);
+ this.showAlert('success', 'Slip Details Captured', [
+ `${result.lines.length} demand drafts captured in favour of ${result.lines
+ .map((l: DdSlipLine) => l.beneficiaryName)
+ .join(', ')}.`,
+ `₹${this.ddTotalPayableFor(row)} will be debited from ${isGlDebit ? 'GL account ' : ''}${row.acNo} as a single ${
+ isGlDebit ? 'journal entry' : 'transaction'
+ } once a checker approves.`,
+ 'DD numbers are allocated on submission. Press Save to send this slip for approval.'
+ ]);
+ return;
+ }
+
row.ddDetails = {
leafletId: leaflet.id,
ddNumber: leaflet.ddNumber,
@@ -2510,29 +2818,32 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
/**
- * Raises a Demand Draft funded by debiting a member's savings or current account.
+ * Raises a Demand Draft funded by debiting a member's savings/current account, or a GL account
+ * directly.
*
- * Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The member's
- * account is only touched when a checker approves, which is also when the balance is re-checked —
- * so a draft raised against a sufficient balance can still be refused if the balance has since
- * gone. No denominations are sent, because no cash is taken.
+ * Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The
+ * account/GL is only touched when a checker approves, which for a member account is also when the
+ * balance is re-checked — so a draft raised against a sufficient balance can still be refused if
+ * the balance has since gone (a GL account carries no such balance check). No denominations are
+ * sent, because no cash is taken.
*
- * The account goes in savingsAccountId or currentAccountId, never both: the server insists on
- * exactly one, since which table the account lives in decides which control GL is debited.
+ * The funding account goes in exactly one of savingsAccountId, currentAccountId or glAccountId: the
+ * server insists on exactly one, since which one decides what gets debited on approval.
*/
private submitAccountDebitDd(
row: TxnRow,
leaflet: any,
beneficiaryData: any,
fundingAccountId: number,
- fundingClientId: number
+ fundingClientId: number | null
): void {
const d = new Date();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
+ const isGl = row.acTyp === 'GL';
const payload = {
- clientId: fundingClientId,
+ clientId: isGl ? undefined : (fundingClientId ?? undefined),
beneficiaryName: beneficiaryData.beneficiaryName,
payableAtBranch: beneficiaryData.payableAtBranch,
payableAtCity: beneficiaryData.payableAtCity,
@@ -2540,9 +2851,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
chargesAmount: Number(row.ddCharges) || 0,
issuingBranchId: row.br,
issuedDate,
- paymentMethod: 'ACCOUNT_DEBIT' as const,
- savingsAccountId: isCurrent ? undefined : fundingAccountId,
- currentAccountId: isCurrent ? fundingAccountId : undefined
+ paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
+ savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
+ currentAccountId: isGl || !isCurrent ? undefined : fundingAccountId,
+ glAccountId: isGl ? fundingAccountId : undefined
};
this.saving = true;
@@ -2576,7 +2888,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
* got none has no way of telling which row was missing.
*/
private saveDdRows(ddRows: TxnRow[]): void {
- const unfilled = ddRows.filter((r) => !r.ddDetails);
+ const unfilled = ddRows.filter((r) => !r.ddDetails && !r.ddLines?.length);
if (unfilled.length > 0) {
this.showAlert('error', 'DD Details Not Entered', [
unfilled.length === 1
@@ -2588,6 +2900,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
for (const row of ddRows) {
+ // A slip goes to the batch endpoint, which allocates its leaflets and posts one debit for the
+ // whole run. Sending its lines one at a time through the single-DD endpoint would debit the
+ // member once per draft — the very thing a slip exists to avoid.
+ if (row.ddLines?.length) {
+ this.submitDdSlip(row);
+ continue;
+ }
+
const details = row.ddDetails!;
const leaflet = { id: details.leafletId, ddNumber: details.ddNumber };
if (this.isDdAccountDebitRow(row)) {
@@ -2615,6 +2935,99 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
}
+ /**
+ * Submits a multi-DD slip: several drafts raised together against one debit of the member's
+ * account.
+ *
+ * Maker-checker like the single-draft flow, and nothing is debited here. On approval the server
+ * withdraws the combined face value, charges and GST of every line as ONE transaction and posts
+ * one journal entry against it, then issues each draft — so the member's passbook carries a single
+ * entry naming all the DD numbers rather than one entry per draft. The checker approves or rejects
+ * the slip whole; there is no approving four drafts out of five.
+ *
+ * The leaflets are not chosen here. The server allocates the run inside its own locked transaction
+ * and returns which number went to which line, which is the only point at which the numbers are
+ * real — two clerks submitting at the same moment can therefore never be handed the same one.
+ */
+ private submitDdSlip(row: TxnRow): void {
+ const lines = row.ddLines ?? [];
+ const inventoryId = row.ddInventoryId;
+ const fundingAccountId = row.ddFundingAccountId;
+ const fundingClientId = row.ddFundingClientId;
+
+ const isGl = row.acTyp === 'GL';
+
+ // Re-checked at Save, not just when the details were captured: the row's TYPE, AC.TP or account
+ // number can all be edited afterwards, and a slip that has drifted onto a cash row is one the
+ // batch endpoint refuses outright. A GL slip is raised against no member, so it carries no
+ // client id and none is required of it — the same asymmetry as the single-draft GL path.
+ if (!inventoryId || !fundingAccountId || (!isGl && !fundingClientId) || !this.canRowBeMultiDd(row)) {
+ this.showAlert('error', 'Slip Cannot Be Submitted', [
+ 'This row has changed since its drafts were captured, so the slip no longer matches it.',
+ 'Open "Fill DD Details" again and re-enter the drafts.'
+ ]);
+ return;
+ }
+
+ const d = new Date();
+ const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+ const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
+
+ // The funding source goes in exactly one of savingsAccountId, currentAccountId or glAccountId,
+ // as the single-draft path does: which one is set is what decides the debit leg on approval.
+ const payload = {
+ clientId: isGl ? undefined : fundingClientId,
+ issuingBranchId: row.br,
+ issuedDate,
+ paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
+ savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
+ currentAccountId: !isGl && isCurrent ? fundingAccountId : undefined,
+ glAccountId: isGl ? fundingAccountId : undefined,
+ lines: lines.map((l) => ({
+ beneficiaryName: l.beneficiaryName,
+ payableAtBranch: l.payableAtBranch,
+ payableAtCity: l.payableAtCity,
+ faceValue: Number(l.faceValue),
+ chargesAmount: Number(l.chargesAmount) || 0
+ }))
+ };
+
+ const totalDebited = this.ddTotalPayableFor(row);
+ const accountNo = row.acNo;
+
+ this.saving = true;
+ this.sponsorDDService.submitIssueRequestBatch(inventoryId, payload).subscribe({
+ next: (result: any) => {
+ this.saving = false;
+ // The allocated numbers, named back to the clerk. Until this response they were provisional,
+ // and these are what the customer's drafts will actually carry.
+ const allocated = (result?.lines ?? [])
+ .map((l: any) => `${l.ddNumber} · ${l.beneficiaryName} · ₹${l.faceValue}`)
+ .join('\n');
+ this.showAlert('success', 'DD Slip Submitted', [
+ `Slip of ${lines.length} demand drafts submitted successfully.`,
+ allocated || `${lines.length} drafts allocated.`,
+ `₹${totalDebited} will be debited from ${accountNo} as a single transaction on approval.`,
+ 'Sent to Manager / Head Cashier for maker-checker approval of the whole slip.'
+ ]);
+ row.amount = null;
+ row.ddCharges = null;
+ row.description = '';
+ row.ddCount = 1;
+ // Cleared with the row, as the single-draft paths do: leaving the lines behind would let a
+ // second Save raise the same slip again against leaflets already allocated.
+ this.clearDdCapture(row);
+ },
+ error: (err) => {
+ this.saving = false;
+ this.showAlert('error', 'DD Slip Submission Failed', [
+ err.error?.message || err.message,
+ 'No drafts were issued and nothing has been debited — the whole slip was refused together.'
+ ]);
+ }
+ });
+ }
+
private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void {
// Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked
// right but matched nothing, so every cash DD went in with no breakdown at all and its money
@@ -2802,7 +3215,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Without this the backend defaults every request from this screen to DISBURSEMENT — a
// Collection or Fund Transfer submitted here was being saved (and shown in the checker's
// inbox) as a plain Disbursement.
- requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT'
+ requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT',
+ // Same ambiguity as a cashier Receipt — a Collection credits a GL account picked by hand.
+ // See TxnRow.entryTypeCvId.
+ ...(isCollectionMode ? { entryTypeCvId: crRows[0]?.entryTypeCvId || null } : {})
};
this.saving = true;
@@ -3044,6 +3460,168 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
);
}
+ /**
+ * Share redemption settled to a GL account the clerk picks on the CR row, instead of the
+ * member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
+ * credit side is any GL account (e.g. a "DD Payable" holding account), not a fixed linked
+ * account, so there is no "must match the linked account" check here.
+ */
+ private submitShareGlSettlementTransfer(shareRow: TxnRow, glRow: TxnRow): void {
+ // Belt-and-braces: the TXN TP. dropdown already hides cash payment types for AC.TP = SHR (see
+ // paymentTypesForRow()), but re-check here too in case the payment type was set before that
+ // filter applied.
+ const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === glRow.paymentTypeId);
+ if (!glRow.paymentTypeId) {
+ this.showAlert('error', 'Settlement Payment Type Required', [
+ 'Select a Payment Type on the CR (GL account) row — it governs how the settlement credit posts.'
+ ]);
+ return;
+ }
+ if (settlementPaymentType?.isCashPayment) {
+ this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
+ 'Share redemption settlement is always a book-transfer, never cash.',
+ 'Select Transfer as the Payment Type on the CR row.'
+ ]);
+ return;
+ }
+
+ const shareAccountId = this.rowAccountIds[shareRow.acNo];
+ if (!shareAccountId) {
+ this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
+ return;
+ }
+ const settlementGlAccountId = this.rowAccountIds[glRow.acNo];
+ if (!settlementGlAccountId) {
+ this.showAlert('error', 'GL Account Not Loaded', [`Please load account details for: ${glRow.acNo}`]);
+ return;
+ }
+
+ const shares = Number(shareRow.amount) || 0;
+ if (shares <= 0) {
+ this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
+ return;
+ }
+ if (!shareRow.resolutionNumber?.trim()) {
+ this.showAlert('error', 'Board Resolution Number Required', [
+ 'A share withdrawal must be authorised by a Board Resolution.'
+ ]);
+ return;
+ }
+ if (!shareRow.meetingDate) {
+ this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
+ return;
+ }
+
+ const today = new Date();
+ const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
+
+ // Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
+ // for audit purposes, same as the linked-savings path above.
+ const settlementRowSnapshot = {
+ branch: glRow.br,
+ accountType: glRow.acTyp,
+ accountNo: glRow.acNo,
+ ecsAccountNo: glRow.ecsAcNo || null,
+ dbCr: glRow.dbCr,
+ amount: glRow.amount ?? null,
+ chequeNo: glRow.chequeNo || null,
+ chequeDate: glRow.cheqDate || null,
+ tokenNo: glRow.tokNo || null,
+ description: glRow.description || null,
+ bankName: glRow.bankName || null,
+ routingCode: glRow.routingCode || null,
+ paymentTypeId: glRow.paymentTypeId,
+ paymentTypeName: settlementPaymentType?.name ?? null
+ };
+
+ const payload = {
+ shareAccountId,
+ accountNo: shareRow.acNo,
+ clientId: this.rowClientIds[shareRow.acNo] ?? null,
+ clientName: this.rowAccountNames[shareRow.acNo] ?? null,
+ requestedShares: shares,
+ requestedDate,
+ resolutionNumber: shareRow.resolutionNumber.trim(),
+ meetingDate: shareRow.meetingDate,
+ description: shareRow.description || null,
+ settlementPaymentTypeId: glRow.paymentTypeId,
+ settlementRowSnapshot,
+ settlementMode: 'GL',
+ settlementGlAccountId
+ };
+
+ const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
+ const currentHolding = this.rowShareHoldings[shareRow.acNo] ?? 0;
+ const remainingShares = currentHolding - shares;
+ const willCloseAccount = remainingShares <= 0;
+ const confirmLines = [
+ `Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
+ `Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
+ `Remaining Shares After Withdrawal: ${Math.max(remainingShares, 0)}`,
+ ...(willCloseAccount
+ ? ['⚠ All shares withdrawn — this account will be CLOSED automatically when the Manager approves.']
+ : []),
+ `Board Resolution No.: ${payload.resolutionNumber}`,
+ `Date of Meeting: ${payload.meetingDate}`,
+ `Settlement Credit To (GL): ${glRow.acNo}`,
+ `Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
+ ...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
+ '',
+ willCloseAccount
+ ? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
+ : 'Pending Manager approval — redemption and settlement both post together on approval.'
+ ];
+ this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
+ this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
+ );
+ }
+
+ private doSubmitShareGlSettlementTransfer(
+ payload: Record,
+ shareRow: TxnRow,
+ shares: number,
+ glAcNo: string
+ ): void {
+ this.saving = true;
+ this.clerkShareWithdrawalService
+ .submit(payload)
+ .pipe(
+ catchError((err: any) => {
+ const msg =
+ err?.error?.errors?.[0]?.defaultUserMessage ??
+ err?.error?.developerMessage ??
+ err?.error?.defaultUserMessage ??
+ 'Share withdrawal submission failed. Please try again.';
+ this.showAlert('error', 'Submission Failed', [msg]);
+ this.saving = false;
+ return of(null);
+ })
+ )
+ .subscribe((res: any) => {
+ this.saving = false;
+ if (res) {
+ const willCloseAccount = (this.rowShareHoldings[shareRow.acNo] ?? 0) - shares <= 0;
+ this.showAlert('success', 'Share Withdrawal Submitted', [
+ `${shares} share(s) from ${shareRow.acNo} → settlement to GL ${glAcNo} on approval.`,
+ willCloseAccount
+ ? 'Pending Manager approval — this will close the share account, since all shares will be withdrawn. Track status in "My Share Requests".'
+ : 'Pending Manager approval. Track status in "My Share Requests".'
+ ]);
+ this.rows = [];
+ this.rowAccountIds = {};
+ this.rowAccountNames = {};
+ this.rowSharePrices = {};
+ this.rowClientIds = {};
+ this.rowLinkedSavingsAcNo = {};
+ this.rowShareHoldings = {};
+ this.addRow();
+ this.selectedRow = this.rows[0];
+ this.accountDetails = null;
+ this.clientDetails = null;
+ }
+ });
+ }
+
private doSubmitShareRedemptionTransfer(
payload: Record,
shareRow: TxnRow,
@@ -3103,37 +3681,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.pipe(
catchError((err: any) => {
// Not a withdrawal-request token at all (as opposed to PENDING/WITHDRAWN/REJECTED,
- // which are legitimate matches with their own specific error) — try the dividend
- // cash-claim pool, then the account-closure pool, before giving up: all three share
- // the same TOK. NO. field at this counter.
+ // which are legitimate matches with their own specific error) — try the account-closure
+ // pool before giving up: both share the same TOK. NO. field at this counter. A dividend
+ // claim was once a third pool here, but a dividend never actually pays out in cash over
+ // this counter — it only ever settles through a Demand Draft, which is its own DD
+ // issuance request and its own token, so looking one up here could only ever 404.
if (err?.status === 404) {
- return this.dividendClaimRequestService.getByToken(token).pipe(
- catchError((divErr: any) => {
- if (divErr?.status === 404) {
- return this.closureRequestService.getByToken(token).pipe(
- catchError((closeErr: any) => {
- this.loadingWithdrawal = false;
- const msg: string =
- closeErr?.error?.message ??
- closeErr?.error?.errors?.[0]?.defaultUserMessage ??
- `Token '${token}' could not be verified. Please try again.`;
- this.showAlert('error', `Token '${token}'`, [msg]);
- return of(null);
- }),
- map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
- );
- }
+ return this.closureRequestService.getByToken(token).pipe(
+ catchError((closeErr: any) => {
this.loadingWithdrawal = false;
const msg: string =
- divErr?.error?.message ??
- divErr?.error?.errors?.[0]?.defaultUserMessage ??
+ closeErr?.error?.message ??
+ closeErr?.error?.errors?.[0]?.defaultUserMessage ??
`Token '${token}' could not be verified. Please try again.`;
this.showAlert('error', `Token '${token}'`, [msg]);
return of(null);
}),
- // Only tag a result that actually came from the dividend pool — a closure hit
- // recovered by the inner fallback above is already tagged and must stay that way.
- map((divReq: any) => (divReq && !divReq.__closureRequest ? { __dividendClaim: true, ...divReq } : divReq))
+ map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
);
}
this.loadingWithdrawal = false;
@@ -3159,7 +3723,16 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.clientDetails = null;
this.accountError = '';
row.acNo = req.client_name ?? 'Dividend Claim #' + req.id;
+ // Marks this acNo as already resolved, the same way loadAccountDetails() itself would —
+ // without it, the next click into this row (onRowSelect) sees an acNo that was never
+ // "loaded" and re-searches for a real account matching this stub string, which can land
+ // on an unrelated real account whose number happens to share a suffix with it.
+ this.lastLoadedAcNo = row.acNo;
row.acTyp = 'SD';
+ // Every token-driven row is cash leaving the branch to a member, which is what the
+ // Payment register is for — not what this row's TYPE happened to default to before the
+ // token was entered.
+ row.typ = 'Payment';
row.amount = Number(req.amount) || null;
row.dbCr = 'Dr';
this.selectedRow = row;
@@ -3181,7 +3754,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.clientDetails = null;
this.accountError = '';
row.acNo = req.account_no ?? '';
+ // See the same line in the dividend-claim branch above for why this is required.
+ this.lastLoadedAcNo = row.acNo;
row.acTyp = 'SD';
+ row.typ = 'Payment';
row.amount = Number(req.cashier_disbursed_amount) || null;
row.dbCr = 'Dr';
// A closure request stores payment_type_id as NULL, meaning "Cash (Default)" — resolve the
@@ -3194,15 +3770,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
this.activeWithdrawalRequest = req;
+ // A plain withdrawal-slip request carries its own real account_type (SAVINGS, CURRENT,
+ // FIXED_DEPOSIT or RECURRING_DEPOSIT — see ClerkWithdrawalRequestApiResource.submit()), unlike
+ // the closure/dividend pools above, which are never anything but a savings-shaped stub.
+ // Hardcoding 'SD' here searched every non-savings account on the wrong endpoint (Current
+ // accounts live under /currentaccounts, not /savingsaccounts) and showed it mislabelled even
+ // when the lookup happened to succeed.
+ const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
row.acNo = req.account_no ?? '';
- row.acTyp = 'SD';
+ row.acTyp = acTyp;
+ row.typ = 'Payment';
row.amount = Number(req.amount) || null;
row.dbCr = 'Dr';
row.paymentTypeId = req.payment_type_id ?? null;
row.description = req.description ?? '';
this.selectedRow = row;
if (req.account_no) {
- this.loadAccountDetails(req.account_no.trim(), 'SD');
+ this.loadAccountDetails(req.account_no.trim(), acTyp);
}
});
}
@@ -3229,10 +3813,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
onAcNoBlur(row: TxnRow, index: number): void {
if (!row.acNo.trim()) return;
this.selectedRow = row;
+ // A token-driven row's account fields are already authoritative (see onTokNoBlur) -- merely
+ // clicking into and out of the A/C NO. field here must not re-search for them. Doing so used
+ // row.acTyp's internal stand-in value (e.g. 'SD' for a dividend claim, which is never the
+ // account's real type) against the account's real number, either finding nothing ("Account
+ // not found") or, worse, matching a completely unrelated real account that happened to share a
+ // number suffix.
+ if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
+ return;
+ }
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
onAcNoInput(row: TxnRow, index: number, value: string): void {
+ // Same reasoning as the guard at the top of onAcNoBlur() -- a token-driven row's account
+ // fields must not be edited or re-searched via this field at all.
+ if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
+ return;
+ }
row.acNo = value;
this.lastLoadedAcNo = '';
this.acNoInput$.next({ row, index, value });
@@ -3341,8 +3939,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const match = exactMatch ?? partialMatch ?? idMatch ?? null;
if (match?.id) {
const matchedGlCode = match[f] ?? acNo;
- if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) {
- this.selectedRow.acNo = match.glCode;
+ // Any row resolved via the partial/endsWith/id fallback above (not an exact code match)
+ // loads and displays the right account, but the row itself still carries whatever shorter
+ // string the cashier actually typed to search with -- and that's what gets submitted and
+ // shown everywhere downstream (vouchers, My Work, credit_entries), not the account's real
+ // full number. Correct the row to the account's actual code whenever the match wasn't
+ // already exact, for every account type -- not just the DD/GL cases this was previously
+ // limited to.
+ if (this.selectedRow && !exactMatch && matchedGlCode) {
+ this.selectedRow.acNo = matchedGlCode;
}
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch);
@@ -4519,6 +5124,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// "Disbursement". 'Enter Bank Transfer' never carries a GL leg, so it always stays in this
// branch; only skip out to the Disbursement/Collection branch below when every row is 'Fund
// Transfer' and at least one of them is GL.
+ // Share redemption settled to a GL account instead of the linked savings account: DR=Share
+ // account, CR=any GL account the clerk picks (e.g. a "DD Payable" holding account, ahead of a
+ // second, independent manual entry that later moves the value out to an actual draft). Checked
+ // ahead of both branches below — the Enter Bank Transfer/Fund Transfer branch would otherwise
+ // treat a Fund Transfer carrying a GL leg as the Disbursement/Collection shape (see
+ // fundTransferRowsHaveGl below), and a Share account can never be debited by that path at all —
+ // no such Fineract command exists — so it would either be silently mis-posted or rejected late.
+ if (
+ this.isClerk &&
+ this.rows.length === 2 &&
+ this.rows.every((r) => r.typ === 'Fund Transfer' || r.typ === 'Enter Bank Transfer') &&
+ this.rows.some((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR') &&
+ this.rows.some((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')
+ ) {
+ const shareRow = this.rows.find((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR')!;
+ const glRow = this.rows.find((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')!;
+ this.submitShareGlSettlementTransfer(shareRow, glRow);
+ return;
+ }
+
const fundTransferRowsHaveGl =
this.rows.every((r) => r.typ === 'Fund Transfer') && this.rows.some((r) => r.acTyp === 'GL');
if (
@@ -5179,13 +5804,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
- // Cashier deposit (savings/current/GL) → route through maker-checker (create PENDING request, no direct credit)
+ // Cashier deposit (savings/current/FD/RD/GL) → route through maker-checker (create PENDING request,
+ // no direct credit). FD and RD are their own account-type codes (see creditEntryAccountType()) and
+ // belong here just as much as a plain savings deposit -- a cashier crediting an FD/RD must not be
+ // able to move money in without a manager's approval either.
// Use the dialog-confirmed amount — for cash that is the denomination total
if (
!this.isClerk &&
!this.reviewMode &&
isDeposit &&
- (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')
+ (row.acTyp === 'SD' ||
+ row.acTyp === 'CA' ||
+ row.acTyp === 'CB' ||
+ row.acTyp === 'FD' ||
+ row.acTyp === 'RD' ||
+ row.acTyp === 'GL')
) {
this.submitCashierDepositRequest(row, this.confirmData?.amount ?? amount);
return;
@@ -5650,6 +6283,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
chequeNo: row.chequeNo || null,
bankName: row.bankName || null,
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null,
+ // What this GL credit is actually for — see TxnRow.entryTypeCvId. Sent for every GL deposit
+ // (the backend defaults to "General" when the counter didn't pick one), so Share Advance
+ // Collection and Sponsor DD issuance can tell whose voucher this really is before adopting it.
+ ...(isGl ? { entryTypeCvId: row.entryTypeCvId || null } : {}),
// DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face
// value/charges/GST back once this request is approved, instead of retyping them.
...(this.isDdCashGlRow(row)
diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html
index b4ae05100..98a1c34c5 100644
--- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html
+++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html
@@ -1142,6 +1142,46 @@
+
+
+
+
Risk Profile
+ Used by the periodic risk review
+
+
+
+
+ Politically Exposed Person
+ Holds the rating at High; never lowered on ordinary activity
+
+
+ Sanctions / watch-list match
+ UNSC, MHA or internal watch list
+
+
+ Adverse media / regulatory action
+ FIR, court proceedings or published action
+
+
+
+
+
+ Declared annual income (₹)
+
+ As stated by the customer
+
+
+
+ Declared annual turnover (₹)
+
+ For a business customer
+
+
+
+
diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss
index 0f4520920..032060e25 100644
--- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss
+++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss
@@ -1807,3 +1807,28 @@
color: #15803d;
}
}
+
+/* ── Risk profile ────────────────────────────────────────────────────────────
+ Determinations about the customer, which the periodic risk review reads directly. Grouped with
+ the document block so the form reads as "what we know about them" rather than scattering a PEP
+ flag among the identifier fields. */
+
+.kyc-doc-fields__hint {
+ font-size: 12px;
+ color: #64748b;
+}
+
+.kyc-risk-flags {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 4px 0 14px;
+}
+
+/* Sits under its checkbox label: the consequence of ticking the box matters more than the box. */
+.kyc-risk-hint {
+ display: block;
+ font-size: 12px;
+ color: #64748b;
+ line-height: 1.4;
+}
diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
index 32587d1e9..c95341dee 100644
--- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
+++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
@@ -140,6 +140,13 @@ export class KycTabComponent implements OnInit, OnDestroy {
1,
[Validators.required]
],
+ // Risk profile. Determinations about the customer, which the periodic risk review reads directly - a PEP or a
+ // sanctions match holds the rating at High whatever the period's activity looks like.
+ isPep: [false],
+ sanctionsMatch: [false],
+ adverseMediaMatch: [false],
+ declaredAnnualIncome: [null],
+ declaredAnnualTurnover: [null],
// The officially valid document, so a lapsed one can be re-collected on this form. The
// backend has always accepted these four; only the fields were missing.
kycDocumentName: [''],
@@ -224,6 +231,11 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.kycForm.patchValue({
kycType: data.kycType || 1,
riskCategory: data.riskCategory || 1,
+ isPep: !!data.isPep,
+ sanctionsMatch: !!data.sanctionsMatch,
+ adverseMediaMatch: !!data.adverseMediaMatch,
+ declaredAnnualIncome: data.declaredAnnualIncome ?? null,
+ declaredAnnualTurnover: data.declaredAnnualTurnover ?? null,
panNumber: data.panNumber || '',
// Aadhaar is stored masked on the server; user must re-enter
// the full 12-digit number to re-verify, so don't pre-fill.
@@ -449,6 +461,28 @@ export class KycTabComponent implements OnInit, OnDestroy {
return new Date();
}
+ /**
+ * The customer's risk profile, as every save has to carry it.
+ *
+ *
+ * Sent from every path that writes the KYC record rather than only the main Save, because the form holds one set of
+ * values: a save triggered from the PAN declaration or the Aadhaar receipt would otherwise write the record without
+ * these and silently clear a PEP flag the officer had just ticked.
+ */
+ private buildRiskProfileFields() {
+ const v = this.kycForm.value;
+ return {
+ isPep: !!v.isPep,
+ sanctionsMatch: !!v.sanctionsMatch,
+ adverseMediaMatch: !!v.adverseMediaMatch,
+ // Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
+ // customer who declared no income at all.
+ declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
+ declaredAnnualTurnover:
+ v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
+ };
+ }
+
private buildExceptionFields() {
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
@@ -487,7 +521,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(),
- ...this.buildExceptionFields()
+ ...this.buildRiskProfileFields(),
+ ...this.buildExceptionFields()
};
if (!this.kycId) {
@@ -533,6 +568,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
remarks: this.kycForm.value.remarks || null,
riskCategory: this.kycForm.value.riskCategory || 1,
dateFormat: 'dd MMMM yyyy',
+ ...this.buildRiskProfileFields(),
...this.buildExceptionFields()
};
@@ -732,7 +768,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat,
riskCategory: this.kycForm.value.riskCategory || 1,
...declarationFields,
- ...this.buildExceptionFields()
+ ...this.buildRiskProfileFields(),
+ ...this.buildExceptionFields()
};
this.clientsService
@@ -892,7 +929,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat,
riskCategory: this.kycForm.value.riskCategory || 1,
...receiptFields,
- ...this.buildExceptionFields()
+ ...this.buildRiskProfileFields(),
+ ...this.buildExceptionFields()
};
this.clientsService
diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html
index cf61082bd..3351e3022 100644
--- a/src/app/core/shell/shell.component.html
+++ b/src/app/core/shell/shell.component.html
@@ -310,41 +310,6 @@
}
}
-
-
-
- CASH₹14,82,500
- │
-
- TXN TODAY
- 1,247
- ↑ 8.2%
-
- │
- CLEARING IN42
- │
- CLEARING OUT38
- │
-
- NPA·GROSS
- 3.47%
- ↑ 0.12
-
- │
- CASA58.2%
- │
- CD RATIO71.8%
- │
-
- RBI CIRC
- DBR.AML.NO.4/14.01.001
- NEW
-
- │
- REPO6.50%
- │
-
-
-
- Pending awaiting Whitelist/Escalate decision ·
- → STR escalated to Principal Officer as an STR case
-
-
-
@@ -590,7 +520,7 @@
- @for (sc of amlStrCases; track sc.id) {
+ @for (sc of liveStrCases; track sc.id) {
{{ sc.id }}
{{ sc.clientDisplayName }}
@@ -607,13 +537,50 @@
} @empty {
-
No STR cases yet.
+
No STR cases outstanding.
}
+
+ @if (filedStrCases.length) {
+
+
+
Filed with FIU-IND
+
Reported and closed — kept for the record, with the reference the filing was made under.
+
+
+
+
+
+
+
ID
+
Client
+
Conclusion
+
Filed On
+
FIU Reference
+
Action
+
+
+
+ @for (sc of filedStrCases; track sc.id) {
+
+
{{ sc.id }}
+
{{ sc.clientDisplayName }}
+
{{ sc.conclusion || '—' }}
+
{{ asDate(sc.strFiledOn) | date: 'dd MMM yyyy' }}
+
{{ sc.strReferenceNo || '—' }}
+
View
+
+ }
+
+
+
+ }
+
@if (selectedStrCase) {
diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts
index 25f51ebcf..e918bce81 100644
--- a/src/app/fiu/fiu.component.ts
+++ b/src/app/fiu/fiu.component.ts
@@ -7,6 +7,7 @@
*/
import { Component, OnInit, inject } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -25,8 +26,7 @@ import {
KycIndividual,
PoFinding,
StrStatus,
- StrTransaction,
- STR_TXNS
+ StrTransaction
} from './fiu-data';
/** A FINnet file rendered as a grid — the same rows that go into the CSV. */
@@ -130,6 +130,7 @@ export class FiuComponent implements OnInit {
private amlService = inject(AmlService);
private sanctionsLogsService = inject(SanctionsLogsService);
private authenticationService = inject(AuthenticationService);
+ private route = inject(ActivatedRoute);
/** Active tab */
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
@@ -154,7 +155,16 @@ export class FiuComponent implements OnInit {
* is honestly empty rather than populated with rows that cannot be filed.
*/
tc1Txns: CardTransaction[] = [];
- strTxns: StrTransaction[] = STR_TXNS;
+ /**
+ * The STR transaction file.
+ *
+ * Empty, for the same reason TC1 and the customer cards are. The four rows that sat here named customers this bank
+ * has never onboarded, against account numbers it does not hold, for sums it never received — on the screen an
+ * officer files suspicious-transaction reports from. A filing built on invented rows is not a draft; it is a false
+ * report to FIU-IND. The live alerts the engine has actually escalated are listed on this tab already, and the
+ * file fills from those when the STR export is wired to them.
+ */
+ strTxns: StrTransaction[] = [];
/**
* KC1 / KC2 — the customer cards that travel with the CTR bundle.
*
@@ -210,13 +220,29 @@ export class FiuComponent implements OnInit {
*/
sanctionsStrCases: any[] = [];
sanctionsStrFilter = '';
- amlAlertStatusFilter = '';
- amlReviewNotes: Record = {};
amlCtrReports: any[] = [];
ctrGenerateMonth = '';
amlStrCases: any[] = [];
+
+ /**
+ * The cases still carrying work, which is what "Live" means here.
+ *
+ *
+ * A case that has been filed with FIU-IND is finished: the report is gone, the reference number is on it, and there
+ * is nothing left for the Principal Officer to do. Leaving it in the live queue is how a working list stops being
+ * one — every filed case stays on it for good, and an officer opening the tab has to tell the outstanding work from
+ * the closed by reading a status column. Filed cases are listed below, where they can still be looked up.
+ */
+ get liveStrCases(): any[] {
+ return this.amlStrCases.filter((c) => c?.status !== 'FILED');
+ }
+
+ /** Cases already filed with FIU-IND, kept for the record rather than as work. */
+ get filedStrCases(): any[] {
+ return this.amlStrCases.filter((c) => c?.status === 'FILED');
+ }
selectedStrCase: any = null;
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
concludeInvestigationNotes = '';
@@ -398,6 +424,14 @@ export class FiuComponent implements OnInit {
this.isHeadOffice = this.authenticationService.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office';
+ // Opened on a particular tab when another screen sends the officer here for a reason - the Risk
+ // Review Report links a case that is with the Principal Officer straight to the STR queue, so
+ // landing on the dashboard would make them find it themselves.
+ const wantedTab = this.route.snapshot.queryParamMap.get('tab');
+ if (wantedTab === 'str' || wantedTab === 'ctr' || wantedTab === 'customers' || wantedTab === 'files') {
+ this.activeTab = wantedTab;
+ }
+
// The header named a hard-coded officer and a hard-coded month — "Karthik Kamath" and
// "January 2026" — on a screen whose whole purpose is to say which month is being filed and
// by whom. Both now come from the session and the clock.
@@ -615,10 +649,9 @@ export class FiuComponent implements OnInit {
}
/**
- * Always fetches every status. The triage table and the cleared register are two views of the
- * same set, so filtering server-side would empty one whenever the other was narrowed — and the
- * CTR tab reads this list too, which a status filter chosen on the STR tab should not touch.
- * amlAlertStatusFilter is applied client-side in {@link amlSuspiciousAlerts} instead.
+ * Always fetches every status. This tab shows what was cleared and what became an STR case, which are two views of
+ * the same set, and the CTR tab reads the list as well — so narrowing it server-side for one view would empty
+ * another.
*/
loadAmlAlerts(): void {
this.amlService.getAlerts().subscribe((data) => (this.amlAlerts = data || []));
@@ -762,48 +795,6 @@ export class FiuComponent implements OnInit {
return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`);
}
- /**
- * Clearing or escalating an alert has to carry a reason: review_notes is the only audit record of
- * why a flagged transaction was disposed of, and an empty string leaves no trace of the decision.
- */
- private reviewNotesOf(alert: any): string | null {
- const notes = (this.amlReviewNotes[alert.id] || '').trim();
- if (!notes) {
- this.toast(`Enter review notes before disposing of alert #${alert.id}`);
- return null;
- }
- return notes;
- }
-
- whitelistAlert(alert: any): void {
- const notes = this.reviewNotesOf(alert);
- if (!notes) {
- return;
- }
- this.amlService.reviewAlert(alert.id, 'WHITELIST', notes).subscribe({
- next: () => {
- this.toast(`Alert #${alert.id} whitelisted — no STR raised`);
- this.loadAmlAlerts();
- },
- error: this.apiError(`Whitelisting alert #${alert.id}`)
- });
- }
-
- escalateAlert(alert: any): void {
- const notes = this.reviewNotesOf(alert);
- if (!notes) {
- return;
- }
- this.amlService.reviewAlert(alert.id, 'ESCALATE', notes).subscribe({
- next: () => {
- this.toast(`Alert #${alert.id} escalated — now an STR case for the Principal Officer`);
- this.loadAmlAlerts();
- this.loadAmlStrCases();
- },
- error: this.apiError(`Escalating alert #${alert.id}`)
- });
- }
-
generateCtrReport(): void {
if (!this.ctrGenerateMonth) {
this.toast('Pick a reporting month first');
@@ -991,9 +982,16 @@ export class FiuComponent implements OnInit {
get liveStrFiled(): number {
return this.amlStrCases.filter((c) => c.status === 'FILED').length;
}
- /** Still needs PO action: untouched, or concluded suspicious but not yet filed with FIU-IND. */
+ /**
+ * Still needs PO action: anything neither filed with FIU-IND nor concluded clear.
+ *
+ * Counted by what is unfinished rather than by naming the statuses that are. Adding up OPEN and CONCLUDED_SUSPICIOUS
+ * gives the same figure today, but it is the shape that fails quietly: a case in any status those two do not name
+ * drops out of the KPI while still sitting in the queue below it, and the dashboard then under-reports the work.
+ * That is exactly how five undecided alerts hid behind a pending count of 1 on the flagged-transactions screen.
+ */
get liveStrPending(): number {
- return this.liveStrOpen + this.liveStrAwaitingFiling;
+ return this.amlStrCases.filter((c) => c?.status !== 'FILED' && c?.status !== 'CONCLUDED_CLEAR').length;
}
get totalCustomers(): number {
return this.kc1.length + this.kc2.length;
@@ -1022,12 +1020,6 @@ export class FiuComponent implements OnInit {
* The STR triage queue: still to decide, or already escalated. Whitelisted alerts are closed
* with no report, so they are not STR business — they live in {@link amlWhitelistedAlerts}.
*/
- get amlSuspiciousAlerts(): any[] {
- return this.behaviouralAlerts.filter(
- (a) => a.status !== 'WHITELISTED' && (!this.amlAlertStatusFilter || a.status === this.amlAlertStatusFilter)
- );
- }
-
/** Cleared register: alerts closed as false positives, kept for audit with their evidence. */
get amlWhitelistedAlerts(): any[] {
return this.behaviouralAlerts.filter((a) => a.status === 'WHITELISTED');
diff --git a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss
index 13b5f7636..dc84e930b 100644
--- a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss
+++ b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.scss
@@ -576,13 +576,11 @@
}
/* ───────── Print — only the page, no toolbar / dialog chrome ─────────
- This dialog is opened with `maxHeight: '92vh'`, which Angular Material carries onto the
- overlay pane / .mat-mdc-dialog-container as an inline max-height + overflow:auto clip.
- `position: absolute` still lives inside that clip and gets cut off — only the on-screen
- scroll position prints. `position: fixed` escapes it: a fixed element's containing block is
- the viewport itself, not the clipped ancestor (the classic "fixed child ignores an
- overflow:hidden parent" behaviour), as long as nothing between here and the viewport sets a
- transform/filter — the CDK overlay chrome doesn't. */
+ The document-level clip (`html, body { overflow: hidden }`) is lifted globally in main.scss's
+ print block; what remains here is this dialog's own clip — it opens with `maxHeight: '92vh'`,
+ which Angular Material carries onto the overlay pane / .mat-mdc-dialog-container as an inline
+ max-height plus overflow:auto. Freeing those lets the whole certificate flow onto the page in
+ normal static order (a fixed/absolute hack would pin it to the first page instead). */
@media print {
body > :not(.cdk-overlay-container) {
display: none !important;
@@ -611,9 +609,7 @@
}
.cpd-root {
- position: fixed;
- left: 0;
- top: 0;
+ position: static;
width: 100% !important;
height: auto !important;
background: #fff;
diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html
index daccfbafa..945e5f5a8 100644
--- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html
+++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html
@@ -21,14 +21,31 @@
{{ result.declarationsCreated }}
{{ result.declarationsCreated === 1 ? 'declaration' : 'declarations' }} created. Each still needs approval
before members can claim.
+ @if (hasSkipped) {
+ Members of the skipped {{ result.skipped.length === 1 ? 'product' : 'products' }} below will receive
+ nothing for this year — check the reason and declare separately if that is not intended.
+ }
+ {{ declareForm.value.ratePercentage }}% is above the society's maximum of
+ {{ preview.maximumRatePercentage }}%. Lower the rate, or raise the limit in Configurations if the board
+ has resolved a higher one.
+
+ } @else if (isPeriodNotFinished) {
+
+
+ {{ preview.financialYear }} has not finished yet, so its profit is not final. A dividend can only be
+ declared once the financial year is over.
+
+ } @else if (isUnderfunded) {
Short by {{ preview.fundingShortfall | number: '1.2-2' }}. There is not enough in
{{ preview.profitGlCode }} to fund this dividend — lower the rate, or close the year's profit first.
@@ -146,16 +178,42 @@
{{ declareForm.value.ratePercentage || 0 }}% on each member’s share investment
+
@for (product of eligibleProducts; track product.productId) {
}
diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss
index 334f70aa4..c95fb17a2 100644
--- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss
+++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss
@@ -148,6 +148,14 @@ form {
text-align: right;
}
+// Who a product's amount reaches, set quietly beside the product name so it reads as a
+// qualifier on the amount rather than competing with it.
+.dd-members {
+ margin-left: 8px;
+ font-size: 12px;
+ color: #94a3b8;
+}
+
.dd-note {
margin-top: 12px;
padding: 10px 14px;
@@ -194,6 +202,11 @@ form {
font-size: 26px;
color: #16a34a;
margin-top: 2px;
+
+ // A run that left a product out is amber, not green: it succeeded, but not for everyone.
+ &--warn {
+ color: #d97706;
+ }
}
.dd-table {
diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts
index 83f7d52e6..5ab5cbd9c 100644
--- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts
+++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts
@@ -73,6 +73,17 @@ export class DeclareDividendComponent implements OnInit {
/** Set once the declaration has gone through, so the outcome per product can be shown. */
result: any = null;
+ /**
+ * Whether any product was left out of the declaration that just ran.
+ *
+ * Drives the outcome heading. A run that skipped a product still returns success — one product
+ * being ineligible is not a reason to lose the others — but those members receive nothing for the
+ * year, so the screen has to say so rather than report a plain "declared".
+ */
+ get hasSkipped(): boolean {
+ return (this.result?.skipped?.length ?? 0) > 0;
+ }
+
ngOnInit(): void {
this.declareForm = this.formBuilder.group({
financialYear: [
@@ -96,9 +107,13 @@ export class DeclareDividendComponent implements OnInit {
/**
* The financial years a dividend can be declared for: the current one and the five before it.
*
- * The current year is included because a society may declare an interim dividend part-way through,
- * and five back covers a declaration delayed by an AGM that ran late. Built from the business date
- * rather than the browser's clock, so the list matches the year the branch is operating in.
+ * The year in progress is listed but cannot be declared — the server rejects it, and the screen
+ * says so as soon as it is picked. It stays on the list so an officer looking for it finds it with
+ * the reason attached, rather than wondering why the year they are in is missing. Five back covers
+ * a declaration delayed by an AGM that ran late.
+ *
+ * Built from the business date rather than the browser's clock, so the list matches the year the
+ * branch is operating in.
*/
private buildFinancialYears(): void {
const businessDate: Date = this.settingsService.businessDate ?? new Date();
@@ -171,8 +186,36 @@ export class DeclareDividendComponent implements OnInit {
return !!this.preview?.exceedsDistributableProfit;
}
+ /**
+ * Whether the chosen year is still running.
+ *
+ * A dividend is a share of the year's profit, so it cannot be declared until the year is over and
+ * that profit is final. The server enforces this; the flag simply lets the screen say so before the
+ * officer commits to a rate.
+ */
+ get isPeriodNotFinished(): boolean {
+ return !!this.preview?.periodNotFinished;
+ }
+
+ /**
+ * Whether the entered rate is above the society's configured maximum.
+ *
+ * The bye-laws cap what may be paid in a year, so a rate above it needs a board resolution rather
+ * than a typed figure. The server enforces the same limit.
+ */
+ get exceedsMaximumRate(): boolean {
+ return !!this.preview?.exceedsMaximumRate;
+ }
+
get canSubmit(): boolean {
- return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0 && !this.isUnderfunded;
+ return (
+ this.declareForm.valid &&
+ !this.submitting &&
+ this.eligibleProducts.length > 0 &&
+ !this.isUnderfunded &&
+ !this.isPeriodNotFinished &&
+ !this.exceedsMaximumRate
+ );
}
submit(): void {
diff --git a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts
index 14693833d..4cb5e33d2 100644
--- a/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts
+++ b/src/app/products/share-products/share-product-stepper/share-product-accounting-step/share-product-accounting-step.component.ts
@@ -70,16 +70,9 @@ export class ShareProductAccountingStepComponent implements OnInit {
shareProductAccountingForm: UntypedFormGroup;
accountingSectionConfigs: ShareAccountingSectionConfig[] = [];
- assetAccountData: GLAccount[] = [];
liabilityAccountData: GLAccount[] = [];
incomeAccountData: GLAccount[] = [];
equityAccountData: GLAccount[] = [];
- /** "Share Reference" is where a redemption's credit side lands pending settlement — some banks
- * model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default),
- * others as a payable-like Liability suspense account (e.g. an existing "Sundry" account
- * already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts
- * either instead of Asset-only. */
- referenceAccountData: GLAccount[] = [];
constructor() {
this.createShareProductAccountingForm();
@@ -87,18 +80,15 @@ export class ShareProductAccountingStepComponent implements OnInit {
ngOnInit() {
const opts = this.shareProductsTemplate.accountingMappingOptions || {};
- this.assetAccountData = opts.assetAccountOptions || [];
this.liabilityAccountData = opts.liabilityAccountOptions || [];
this.incomeAccountData = opts.incomeAccountOptions || [];
this.equityAccountData = opts.equityAccountOptions || [];
- this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData);
this.buildAccountingSectionConfigs();
if (this.shareProductsTemplate.accountingMappings) {
const m = this.shareProductsTemplate.accountingMappings;
this.shareProductAccountingForm.patchValue({
- shareReferenceId: m.shareReferenceId?.id || '',
shareSuspenseId: m.shareSuspenseId?.id || '',
incomeFromFeeAccountId: m.incomeFromFeeAccountId?.id || '',
shareEquityId: m.shareEquityId?.id || ''
@@ -109,10 +99,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
createShareProductAccountingForm() {
this.shareProductAccountingForm = this.formBuilder.group({
accountingRule: [2],
- shareReferenceId: [
- '',
- Validators.required
- ],
shareSuspenseId: [
'',
Validators.required
@@ -125,7 +111,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
'',
Validators.required
],
- assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]),
incomeOtherGlMappings: this.formBuilder.array([]),
equityOtherGlMappings: this.formBuilder.array([])
@@ -134,24 +119,6 @@ export class ShareProductAccountingStepComponent implements OnInit {
buildAccountingSectionConfigs() {
this.accountingSectionConfigs = [
- {
- key: 'assets',
- title: 'Assets',
- pillLabel: 'Asset',
- pillClass: 'green',
- rows: [
- {
- key: 'shareReferenceId',
- label: 'Share Reference (Cash Vault or Suspense)',
- inputLabel: 'Share reference',
- controlName: 'shareReferenceId',
- options: this.referenceAccountData,
- required: true
- }
- ],
- otherMappingsControlName: 'assetsOtherGlMappings',
- otherAccountOptions: this.assetAccountData
- },
{
key: 'liabilities',
title: 'Liabilities',
@@ -239,7 +206,7 @@ export class ShareProductAccountingStepComponent implements OnInit {
}
get shareProductAccounting() {
- const { assetsOtherGlMappings, liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } =
+ const { liabilitiesOtherGlMappings, incomeOtherGlMappings, equityOtherGlMappings, ...rest } =
this.shareProductAccountingForm.value;
return rest;
}
diff --git a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html
index 4bc274f94..4f1064ae8 100644
--- a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html
+++ b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.html
@@ -213,14 +213,6 @@
+ * Low and medium-severity alerts arrive as runs of the same thing - a customer whose ordinary cash
+ * deposits each cross a threshold - and clearing them one dialog at a time is what keeps an officer
+ * from reaching the alert that matters. The reason and evidence are asked once and written against
+ * every alert in the batch, so each still carries its own record of why it was disposed of.
+ *
+ *
+ * High-severity alerts never reach here: the caller will not tick them. An alert in the high band is
+ * a judgement about that particular transaction and gets its own decision.
+ */
+@Component({
+ selector: 'mifosx-aml-bulk-review-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ ReactiveFormsModule,
+ MatDialogModule,
+ MatButtonModule,
+ MatCheckboxModule,
+ MatFormFieldModule,
+ MatInputModule
+ ],
+ template: `
+
+ {{
+ isWhitelist
+ ? 'Cleared as false positives — no report is filed, and the evidence is retained against each alert.'
+ : 'Each alert opens its own STR case for the Principal Officer.'
+ }}
+
+
+
{{ errorMessage }}
+
+
+ Cancel
+
+ {{ submitting ? 'Applying…' : isWhitelist ? 'Whitelist All' : 'Escalate All' }}
+
+
+ `,
+ styles: [
+ `
+ .bk-intro {
+ font-size: 13px;
+ color: #64748b;
+ margin: 0 0 12px;
+ line-height: 1.5;
+ }
+ .bk-list {
+ max-height: 190px;
+ overflow-y: auto;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ margin-bottom: 16px;
+ background: #f8fafc;
+ }
+ .bk-row {
+ display: grid;
+ grid-template-columns: 1.1fr 1.4fr 1.6fr 0.9fr;
+ gap: 10px;
+ padding: 8px 12px;
+ font-size: 12px;
+ border-bottom: 1px solid #eef2f7;
+ }
+ .bk-row:last-child {
+ border-bottom: none;
+ }
+ .bk-txn {
+ font-family: 'DM Mono', monospace;
+ color: #334155;
+ }
+ .bk-client {
+ color: #1e293b;
+ }
+ .bk-rule {
+ color: #7c3aed;
+ font-size: 11px;
+ }
+ .bk-amt {
+ text-align: right;
+ font-family: 'DM Mono', monospace;
+ color: #1e293b;
+ }
+ .bk-label {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #64748b;
+ margin-bottom: 6px;
+ }
+ .bk-checks {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ margin-bottom: 10px;
+ }
+ .bk-full {
+ width: 100%;
+ }
+ .bk-effect {
+ font-size: 12.5px;
+ line-height: 1.5;
+ color: #15803d;
+ background: #f0fdf4;
+ border: 1px solid #bbf7d0;
+ border-radius: 6px;
+ padding: 10px 12px;
+ margin: 4px 0 0;
+ }
+ .bk-effect--warn {
+ color: #b91c1c;
+ background: #fef2f2;
+ border-color: #fecaca;
+ }
+ .bk-err {
+ margin: 10px 0 0;
+ color: #b91c1c;
+ font-size: 12.5px;
+ }
+ `
+ ]
+})
+export class AmlBulkReviewDialogComponent {
+ private fb = inject(FormBuilder);
+ private amlService = inject(AmlService);
+ dialogRef = inject(MatDialogRef);
+
+ readonly evidenceTypes = [
+ 'Customer explanation obtained',
+ 'Source of funds documented',
+ 'KYC / identity re-verified',
+ 'Consistent with declared income or turnover',
+ 'Established business or salary pattern',
+ 'Duplicate or system-generated alert',
+ 'Other'
+ ];
+
+ form: FormGroup;
+ submitting = false;
+ errorMessage = '';
+
+ data: AmlBulkReviewData = inject(MAT_DIALOG_DATA);
+
+ constructor() {
+ this.form = this.fb.group({
+ evidence: this.fb.array(this.evidenceTypes.map(() => false)),
+ evidenceRef: [
+ '',
+ Validators.maxLength(100)
+ ],
+ notes: [
+ '',
+ [
+ Validators.required,
+ Validators.maxLength(800)
+ ]
+ ]
+ });
+ }
+
+ get isWhitelist(): boolean {
+ return this.data.disposition === 'WHITELIST';
+ }
+
+ get evidenceArray(): FormArray {
+ return this.form.get('evidence') as FormArray;
+ }
+
+ get selectedEvidence(): string[] {
+ return this.evidenceTypes.filter((_, i) => this.evidenceArray.at(i)?.value === true);
+ }
+
+ get evidenceMissing(): boolean {
+ return this.isWhitelist && this.evidenceArray.touched && this.selectedEvidence.length === 0;
+ }
+
+ /** Exactly what is written to each alert, so the record reads the same as a single review. */
+ private composedNotes(): string {
+ const v = this.form.value;
+ const detail = (v.notes || '').trim();
+ if (!this.isWhitelist) {
+ return detail ? `Grounds: ${detail}` : '';
+ }
+ const parts: string[] = [];
+ if (this.selectedEvidence.length) {
+ parts.push(`Evidence: ${this.selectedEvidence.join('; ')}`);
+ }
+ if (v.evidenceRef) {
+ parts.push(`Ref: ${(v.evidenceRef || '').trim()}`);
+ }
+ const head = parts.join(' | ');
+ const composed = head && detail ? `${head} — ${detail}` : head || detail;
+ // Batch note, so each alert says it was disposed of alongside others rather than on its own.
+ return `${composed} [batch of ${this.data.alerts.length}]`.slice(0, 1000);
+ }
+
+ submit(): void {
+ this.evidenceArray.markAllAsTouched();
+ if (this.form.invalid || (this.isWhitelist && this.selectedEvidence.length === 0)) {
+ this.form.markAllAsTouched();
+ return;
+ }
+
+ this.submitting = true;
+ this.errorMessage = '';
+ const notes = this.composedNotes();
+
+ // Each alert is reviewed on its own call: one that fails - already disposed of by someone else,
+ // say - must not take the rest of the batch with it. Failures are counted and reported.
+ forkJoin(
+ this.data.alerts.map((a) =>
+ this.amlService
+ .reviewAlert(a.id, this.data.disposition, notes)
+ .pipe(catchError(() => of({ __failed: true, id: a.id })))
+ )
+ ).subscribe((results: any[]) => {
+ this.submitting = false;
+ const failed = results.filter((r) => r?.__failed).length;
+ if (failed === results.length) {
+ this.errorMessage = 'None of the alerts could be updated. They may already have been reviewed.';
+ return;
+ }
+ if (failed > 0) {
+ this.errorMessage = `${results.length - failed} updated, ${failed} could not be — those may already have been reviewed.`;
+ // Still closes as done: most of the batch landed, and the list behind must refresh.
+ setTimeout(() => this.dialogRef.close(true), 2200);
+ return;
+ }
+ this.dialogRef.close(true);
+ });
+ }
+}
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html
index 4470f0f0d..ab2aaf069 100644
--- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html
+++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html
@@ -36,6 +36,17 @@
refresh
+
+
+ file_download
+ {{ exporting ? 'Preparing…' : 'Excel' }}
+
@@ -44,6 +55,52 @@
Could not reach the alert feed. Retrying automatically.
+ @if (isHighSeverity(alert)) {
+ A high-severity rule fired, so this transaction is decided on its own rather than in a batch.
+ } @else if (!alert.highestSeverity) {
+ A statutory return rather than a graded suspicion — decided on its own.
+ } @else {
+ Every rule here is low or medium, so this transaction can be decided together with others.
+ }
+
- {{ activeStatus ? 'filter_alt_off' : 'verified_user' }}
- {{ activeStatus || activeSeverity !== null ? 'filter_alt_off' : 'verified_user' }}
+ No transactions flagged. The feed updates automatically as the engine raises alerts.
- No alerts in this status.
+ No alerts match the filters in force.
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss
index 67a98fce5..a11f5bc7c 100644
--- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss
+++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss
@@ -196,15 +196,43 @@
padding: 0 0 16px;
}
+/* A button, not a div: each tile narrows the table to its own severity band. */
.risk-tile {
display: flex;
flex-direction: column;
+ align-items: flex-start;
gap: 2px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-left-width: 4px;
border-radius: 8px;
background: #fff;
+ font-family: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition:
+ box-shadow 0.15s,
+ transform 0.1s;
+
+ &:hover:not(:disabled) {
+ box-shadow: 0 2px 8px rgb(15 23 42 / 12%);
+ }
+
+ &:active:not(:disabled) {
+ transform: translateY(1px);
+ }
+
+ /* A band with nothing in it would filter to an empty table. */
+ &:disabled {
+ opacity: 0.5;
+ cursor: default;
+ }
+}
+
+/* The band currently narrowing the table. The ring reads on every tile colour, where a
+ background change would fight the severity tint each tile already carries. */
+.risk-tile.tile-active {
+ box-shadow: 0 0 0 2px #1a237e;
}
.risk-tile-n {
@@ -293,8 +321,21 @@
}
}
-.alert-row:hover {
+.alert-row {
+ cursor: pointer;
+
+ &:hover {
+ background: #f8fafc;
+ }
+}
+
+/* The open row and its pane read as one block. */
+.alert-row.row-expanded {
background: #f8fafc;
+
+ td {
+ border-bottom-color: transparent;
+ }
}
/* A newly arrived alert stays tinted until acknowledged. */
@@ -380,6 +421,285 @@
font-size: 12px;
}
+/* Quieter than the governing rule: it is context on one case, not a second case. */
+.rule-also {
+ margin-top: 3px;
+ color: #92400e;
+ background: #fffbeb;
+ border: 1px solid #fde68a;
+ border-radius: 4px;
+ padding: 1px 6px;
+ font-size: 11px;
+ align-self: flex-start;
+}
+
+/* Says how many rules fired and opens the detail. Replaces the block of codes that used to sit in
+ this cell: the codes belong in the pane, where each can carry its own severity. */
+.rule-more {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ margin-top: 4px;
+ padding: 2px 8px 2px 4px;
+ align-self: flex-start;
+ border: 1px solid #fde68a;
+ border-radius: 999px;
+ background: #fffbeb;
+ color: #92400e;
+ font-family: inherit;
+ font-size: 11px;
+ font-weight: 700;
+ cursor: pointer;
+
+ mat-icon {
+ font-size: 15px;
+ width: 15px;
+ height: 15px;
+ }
+
+ &:hover {
+ background: #fef3c7;
+ }
+}
+
+/* ── Expanded detail ─────────────────────────────────────────────────────────────────────── */
+
+.detail-row {
+ height: 0;
+}
+
+.detail-cell {
+ padding: 0 !important;
+ border-bottom: none !important;
+}
+
+/* Animating max-height rather than height: the pane's height depends on how many rules fired, and
+ a transition needs a figure to move between. */
+.detail-pane {
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.2s ease;
+ background: #f8fafc;
+
+ /* Generous enough for a transaction that tripped every rule it could, plus the action bar. Too low
+ silently clips the last rule, which is the one an officer most needs to see. */
+ &.detail-open {
+ max-height: 1200px;
+ }
+}
+
+.detail-inner {
+ padding: 14px 16px 16px 60px;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.detail-head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #64748b;
+ margin-bottom: 10px;
+}
+
+.rule-select-all {
+ border: none;
+ background: none;
+ padding: 0;
+ font-family: inherit;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #1a237e;
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+/* A rule that already carries a verdict is context, not a choice. */
+.detail-rule-decided {
+ opacity: 0.75;
+}
+
+.rule-decided-icon {
+ flex: none;
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ color: #15803d;
+ cursor: help;
+
+ &.decided-escalated {
+ color: #b91c1c;
+ }
+
+ /* Closed alongside another rule rather than judged on its own — stated, but not as a verdict. */
+ &.decided-carried {
+ color: #94a3b8;
+ }
+
+ /* A statutory return: not the officer's decision at all. */
+ &.decided-statutory {
+ color: #475569;
+ }
+}
+
+/* Where a statutory rule goes, said next to the rule rather than left to be inferred from a
+ missing checkbox. */
+.ctr-tag {
+ margin-left: 6px;
+ padding: 1px 6px;
+ border-radius: 4px;
+ background: #e2e8f0;
+ color: #334155;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+.rule-verdict {
+ margin-left: 6px;
+ padding: 1px 6px;
+ border-radius: 4px;
+ background: #dcfce7;
+ color: #15803d;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+
+ &.verdict-escalated {
+ background: #fee2e2;
+ color: #b91c1c;
+ }
+
+ /* Wins over the disposition colours above: this rule was not itself the finding, and colouring it
+ like one would claim the officer condemned it. */
+ &.verdict-carried {
+ background: #f1f5f9;
+ color: #64748b;
+ }
+}
+
+/* Acts on the ticked rules only, and sits with them rather than at the top of the table. */
+.rule-action-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 12px;
+ padding: 10px 12px;
+ border: 1px solid #bfdbfe;
+ border-radius: 8px;
+ background: #eff6ff;
+}
+
+.rule-action-count {
+ font-size: 12.5px;
+ font-weight: 700;
+ color: #1e3a8a;
+ white-space: nowrap;
+}
+
+.detail-rule {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 7px 10px;
+ border-radius: 6px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+
+ & + & {
+ margin-top: 6px;
+ }
+}
+
+/* The rule the alert was raised under, and the one the disposition is recorded against. */
+.detail-rule-governing {
+ border-color: #cbd5e1;
+ box-shadow: inset 3px 0 0 #1a237e;
+}
+
+.detail-rule-text {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+
+.detail-rule-code {
+ font-size: 12.5px;
+ font-weight: 700;
+ color: #1e293b;
+}
+
+.detail-rule-desc {
+ font-size: 12px;
+ color: #64748b;
+}
+
+.governing-tag {
+ margin-left: 6px;
+ padding: 1px 6px;
+ border-radius: 4px;
+ background: #e0e7ff;
+ color: #3730a3;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+}
+
+/* Severity of one rule inside the pane. Same colours as the summary tiles, so a band reads the
+ same wherever it appears. */
+.sev-chip {
+ flex: none;
+ min-width: 62px;
+ text-align: center;
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+
+ &.sev-high {
+ background: #fee2e2;
+ color: #b91c1c;
+ }
+
+ &.sev-medium {
+ background: #fef3c7;
+ color: #b45309;
+ }
+
+ &.sev-low {
+ background: #dcfce7;
+ color: #15803d;
+ }
+
+ &.sev-none {
+ background: #e2e8f0;
+ color: #475569;
+ }
+}
+
+/* Why this transaction can or cannot be decided in a batch, said where the officer is deciding. */
+.detail-foot {
+ margin-top: 10px;
+ font-size: 12px;
+ color: #475569;
+ font-style: italic;
+}
+
.rule-code {
font-weight: 600;
}
@@ -537,3 +857,157 @@
background: #fff;
border-top: 1px solid #e2e8f0;
}
+
+/* Batch disposition bar — only rendered while rows are ticked. */
+.bulk-bar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 16px;
+ margin-bottom: 10px;
+ border: 1px solid #bfdbfe;
+ border-radius: 10px;
+ background: #eff6ff;
+}
+
+.bulk-count {
+ font-size: 13px;
+ font-weight: 700;
+ color: #1e3a8a;
+ white-space: nowrap;
+}
+
+/* Says why some rows cannot be ticked, where the officer is looking rather than in a tooltip. */
+.bulk-note {
+ font-size: 12px;
+ color: #475569;
+}
+
+.bulk-spacer {
+ flex: 1;
+}
+
+.select-cell {
+ width: 44px;
+ padding-right: 0;
+}
+
+/* Stands in for the checkbox on a row that cannot be batched, so the column still reads as a
+ column rather than leaving a hole the eye has to account for. */
+.select-na {
+ display: inline-block;
+ width: 18px;
+ text-align: center;
+ color: #cbd5e1;
+ cursor: help;
+ user-select: none;
+}
+
+/* Day picker. Sized and bordered to sit level with the status chips beside it, so the two rows read
+ as one control strip rather than a form field dropped above a set of buttons. */
+.day-input {
+ padding: 5px 11px;
+ border: 1px solid #cbd5e1;
+ border-radius: 999px;
+ background: #fff;
+ font-family: inherit;
+ font-size: 12.5px;
+ font-weight: 600;
+ color: #334155;
+ cursor: pointer;
+
+ &:hover {
+ border-color: #1a237e;
+ }
+
+ &:focus {
+ outline: none;
+ border-color: #1a237e;
+ }
+}
+
+/* What the chosen mode resolved to. A week picked by naming one day inside it is otherwise invisible — the officer
+ has no way to tell which Monday-to-Sunday span they are looking at. */
+.period-resolved {
+ font-size: 12px;
+ color: #64748b;
+ font-variant-numeric: tabular-nums;
+}
+
+/* Sits with the pause and refresh controls, green because it produces something the officer takes away rather
+ than changing what is on screen. */
+.export-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 13px;
+ border: 1px solid #bbf7d0;
+ border-radius: 999px;
+ background: #f0fdf4;
+ color: #15803d;
+ font-family: inherit;
+ font-size: 12.5px;
+ font-weight: 700;
+ cursor: pointer;
+
+ mat-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ }
+
+ &:hover:not(:disabled) {
+ background: #dcfce7;
+ border-color: #86efac;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+}
+
+/* Held by another officer. Grey, not red: nobody has done anything wrong, the case is simply somebody else's. */
+.held-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ color: #94a3b8;
+ cursor: help;
+}
+
+.held-by {
+ color: #64748b;
+ font-size: 12px;
+ font-style: italic;
+}
+
+/* Rule frequency strip. Sits under the filters as a reading aid, not a control — clicking a rule would be a fourth
+ filter competing with the three above it. */
+.rule-freq {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 0 0 14px;
+}
+
+.rule-freq-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 9px;
+ border: 1px solid #e2e8f0;
+ border-radius: 999px;
+ background: #f8fafc;
+ font-size: 11px;
+ font-weight: 600;
+ color: #475569;
+}
+
+.rule-freq-n {
+ padding: 0 6px;
+ border-radius: 999px;
+ background: #e2e8f0;
+ color: #1e293b;
+ font-variant-numeric: tabular-nums;
+}
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts
index 87cec7b0b..85b49edf4 100644
--- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts
+++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
-import { Component, OnInit, OnDestroy, inject, ViewChild } from '@angular/core';
+import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild } from '@angular/core';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
import { MatIconModule } from '@angular/material/icon';
@@ -15,15 +15,23 @@ import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
+import { MatCheckboxModule } from '@angular/material/checkbox';
import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
import { Subject, timer, of } from 'rxjs';
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
/** Custom Services */
import { AmlService } from '../../../aml/aml.service';
import { AlertService } from '../../../core/alert/alert.service';
+import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component';
+import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component';
+import { exportWorkbook } from '../../../cashier/cashier-export';
+
+/** How the feed is narrowed by time. */
+type PeriodMode = 'all' | 'day' | 'week' | 'month' | 'year';
/**
* Live feed of transactions the AML rule engine has flagged.
@@ -41,6 +49,7 @@ import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.compone
standalone: true,
imports: [
CommonModule,
+ FormsModule,
MatTableModule,
MatPaginatorModule,
MatIconModule,
@@ -48,13 +57,18 @@ import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.compone
MatTooltipModule,
MatProgressSpinnerModule,
MatDialogModule,
+ MatCheckboxModule,
HasPermissionDirective
]
})
-export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
+export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, OnDestroy {
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
+ private auth = inject(AuthenticationService);
+
+ /** Who is signed in, so the screen can tell an alert this officer holds from one somebody else does. */
+ private readonly currentUsername = this.auth.getCredentials()?.username ?? '';
private destroy$ = new Subject();
/** Short enough to feel live to an officer watching the desk, long enough not to hammer the API. */
@@ -62,6 +76,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
alerts = new MatTableDataSource([]);
displayedColumns: string[] = [
+ 'select',
'raisedOn',
'transaction',
'client',
@@ -71,25 +86,532 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
'actions'
];
+ /**
+ * Alerts ticked for a single disposition across several transactions.
+ *
+ *
+ * Low and medium-severity alerts arrive in numbers and are mostly the same decision repeated -
+ * a run of ordinary cash deposits that each tripped a threshold. Dealing with them one dialog at
+ * a time is what stops an officer getting to the one alert that matters. High severity is
+ * deliberately excluded: an alert that reached the high band is a judgement about that customer's
+ * transaction, and it gets its own decision.
+ */
+ selectedIds = new Set();
+
+ /** m_client_risk_rule.risk_category — 3 is the high band. */
+ private static readonly HIGH_SEVERITY = 3;
+
+ /**
+ * Whether any rule this transaction matched reached the high band.
+ *
+ *
+ * Read from highestSeverity, not from the governing rule's own riskCategory. One transaction
+ * raises one alert under whichever rule most warrants attention, and a statutory CTR outranks
+ * everything — so a high-severity rule can sit in "also matched" while the row itself reports the
+ * CTR, which carries no severity at all. Judging the row by the governing rule would offer such a
+ * transaction up for a batch decision.
+ */
+ isHighSeverity(alert: any): boolean {
+ return Number(alert?.highestSeverity) === AmlFlaggedTransactionsComponent.HIGH_SEVERITY;
+ }
+
+ /**
+ * The band this alert counts as, for the summary tiles.
+ *
+ *
+ * The same figure the checkbox is gated on, so the tiles and the table cannot disagree. Reading
+ * riskCategory here put a ₹12,00,000 cash deposit that had tripped a high-severity rule under
+ * "Unclassified", because the alert reports under the statutory CTR and a CTR carries no band of
+ * its own. Alerts raised before highest_severity existed fall back to the governing rule's band.
+ */
+ private severityBand(alert: any): number {
+ const worst = Number(alert?.highestSeverity);
+ if (worst === 1 || worst === 2 || worst === 3) {
+ return worst;
+ }
+ const governing = Number(alert?.riskCategory);
+ if (governing === 1 || governing === 2 || governing === 3) {
+ return governing;
+ }
+ // 0, never NaN: the Unclassified tile filters on this value, and NaN === NaN is false, so a
+ // NaN band would leave that tile showing a count it could never select.
+ return 0;
+ }
+
+ /**
+ * Only an open alert whose every matched rule sits in the low or medium band can be swept up with
+ * others.
+ *
+ *
+ * Two things are held back. A transaction that tripped any high-severity rule, because that is a
+ * judgement about the transaction itself. And one where no matched rule carried a severity at
+ * all, which is how the CTR rules arrive - they are not graded, being returns the bank owes
+ * rather than suspicions. Neither belongs in a batch disposed of on a single reason.
+ */
+ isSelectable(alert: any): boolean {
+ const band = Number(alert?.highestSeverity);
+ const graded = band === 1 || band === 2;
+ return this.canReview(alert) && graded;
+ }
+
+ /** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */
+ expandedId: number | null = null;
+
+ /**
+ * Opens or closes the detail pane. Nothing is recorded by reading.
+ *
+ *
+ * Expanding a row used to mark the alert UNDER_REVIEW, on the reasoning that opening the pane is where the work
+ * starts. It is not: most expands are an officer glancing at what a transaction tripped, and a status that changes
+ * on a single click describes the click rather than any review. The queue filled with alerts nobody was working,
+ * and the one state the flag existed to make visible — this one is actually being worked — became meaningless.
+ */
+ toggleExpanded(alert: any): void {
+ this.expandedId = this.expandedId === alert.id ? null : alert.id;
+ // Ticks belong to the alert they were made on, not to whichever pane is open next.
+ this.selectedRuleCodes.clear();
+ }
+
+ isExpanded(alert: any): boolean {
+ return this.expandedId === alert.id;
+ }
+
+ /**
+ * Every rule the transaction matched, governing one first.
+ *
+ *
+ * Served by the API. The fallback rebuilds the list from the flat fields for an alert read before the API carried
+ * it, so an older row still expands to something rather than appearing to have matched nothing.
+ */
+ matchedRules(
+ alert: any
+ ): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] {
+ if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) {
+ return alert.matchedRules;
+ }
+ const rules = [
+ {
+ ruleCode: alert?.ruleCode,
+ ruleDescription: alert?.ruleDescription,
+ riskCategory: alert?.riskCategory ?? null,
+ governing: true
+ }
+ ];
+ for (const code of String(alert?.alsoMatchedRules || '')
+ .split(',')
+ .map((c: string) => c.trim())
+ .filter((c: string) => c)) {
+ rules.push({ ruleCode: code, ruleDescription: code, riskCategory: null, governing: false });
+ }
+ return rules;
+ }
+
+ /** How many rules the transaction matched, for the count shown on the collapsed row. */
+ matchedRuleCount(alert: any): number {
+ return this.matchedRules(alert).length;
+ }
+
+ severityLabel(band: number | null | undefined): string {
+ switch (Number(band)) {
+ case 1:
+ return 'Low';
+ case 2:
+ return 'Medium';
+ case 3:
+ return 'High';
+ default:
+ return 'Statutory';
+ }
+ }
+
+ /**
+ * Rule codes ticked inside the open detail pane.
+ *
+ *
+ * Cleared whenever a different alert is opened: the codes are only meaningful against the alert they were ticked on,
+ * and carrying them across would apply one transaction's selection to another's rules.
+ */
+ selectedRuleCodes = new Set();
+
+ isRuleSelected(rule: any): boolean {
+ return this.selectedRuleCodes.has(rule.ruleCode);
+ }
+
+ /**
+ * Whether this rule is the officer's to decide.
+ *
+ *
+ * A rule already decided keeps its verdict. A rule with no severity band is not a graded suspicion at all - the CTR
+ * rules are statutory returns the bank owes FIU-IND on any cash transaction at or above ten lakh, filed on the
+ * amount whether or not the customer is above suspicion. There is no "whitelist" for one: clearing the transaction
+ * does not stop the CTR, and escalating it does not describe what the rule found.
+ */
+ isRuleSelectable(rule: any): boolean {
+ return !rule?.disposition && this.isGradedRule(rule);
+ }
+
+ /** A rule that carries a severity band, as against a statutory reporting rule that carries none. */
+ isGradedRule(rule: any): boolean {
+ const band = Number(rule?.riskCategory);
+ return band === 1 || band === 2 || band === 3;
+ }
+
+ toggleRuleSelected(rule: any): void {
+ if (!this.isRuleSelectable(rule)) {
+ return;
+ }
+ if (this.selectedRuleCodes.has(rule.ruleCode)) {
+ this.selectedRuleCodes.delete(rule.ruleCode);
+ } else {
+ this.selectedRuleCodes.add(rule.ruleCode);
+ }
+ }
+
+ selectableRulesOf(alert: any): any[] {
+ return this.matchedRules(alert).filter((r) => this.isRuleSelectable(r));
+ }
+
+ get selectedRuleCount(): number {
+ return this.selectedRuleCodes.size;
+ }
+
+ allRulesSelected(alert: any): boolean {
+ const open = this.selectableRulesOf(alert);
+ return open.length > 0 && open.every((r) => this.selectedRuleCodes.has(r.ruleCode));
+ }
+
+ toggleAllRules(alert: any): void {
+ if (this.allRulesSelected(alert)) {
+ this.selectedRuleCodes.clear();
+ return;
+ }
+ for (const rule of this.selectableRulesOf(alert)) {
+ this.selectedRuleCodes.add(rule.ruleCode);
+ }
+ }
+
+ ruleDispositionLabel(rule: any): string {
+ return rule?.disposition === 'ESCALATE' ? 'Escalated' : rule?.disposition === 'WHITELIST' ? 'Whitelisted' : '';
+ }
+
+ severityClass(band: number | null | undefined): string {
+ switch (Number(band)) {
+ case 1:
+ return 'sev-low';
+ case 2:
+ return 'sev-medium';
+ case 3:
+ return 'sev-high';
+ default:
+ return 'sev-none';
+ }
+ }
+
+ isSelected(alert: any): boolean {
+ return this.selectedIds.has(alert.id);
+ }
+
+ toggleSelected(alert: any): void {
+ if (!this.isSelectable(alert)) {
+ return;
+ }
+ if (this.selectedIds.has(alert.id)) {
+ this.selectedIds.delete(alert.id);
+ } else {
+ this.selectedIds.add(alert.id);
+ }
+ }
+
+ /** The rows a "select all" would act on: what is on screen, open, and below the high band. */
+ get selectableRows(): any[] {
+ return this.alerts.data.filter((a) => this.isSelectable(a));
+ }
+
+ get allSelectableSelected(): boolean {
+ const rows = this.selectableRows;
+ return rows.length > 0 && rows.every((a) => this.selectedIds.has(a.id));
+ }
+
+ toggleSelectAll(): void {
+ if (this.allSelectableSelected) {
+ this.selectedIds.clear();
+ return;
+ }
+ for (const a of this.selectableRows) {
+ this.selectedIds.add(a.id);
+ }
+ }
+
+ get selectedCount(): number {
+ return this.selectedIds.size;
+ }
+
+ private selectedAlerts(): any[] {
+ return this.alerts.data.filter((a) => this.selectedIds.has(a.id));
+ }
+
+ /**
+ * Drops ticks that no longer stand, after every poll.
+ *
+ *
+ * The feed refreshes itself every few seconds while the officer works. Without this, an alert
+ * another officer disposed of in the meantime — or one the status filter has since hidden — stays
+ * in the set, and the bar goes on offering to decide it: the count reads higher than the ticks on
+ * screen, and the batch carries alerts nobody chose.
+ */
+ private pruneSelection(): void {
+ const stillSelectable = new Set(this.selectableRows.map((a) => a.id));
+ for (const id of Array.from(this.selectedIds)) {
+ if (!stillSelectable.has(id)) {
+ this.selectedIds.delete(id);
+ }
+ }
+ // Same for the open detail pane: an alert filtered away or disposed of elsewhere should not
+ // leave the table holding a pane for a row that is no longer on it.
+ if (this.expandedId !== null && !this.alerts.data.some((a) => a.id === this.expandedId)) {
+ this.expandedId = null;
+ }
+ }
+
/** Every alert as returned, before the status filter narrows the table. */
private allAlerts: any[] = [];
readonly statusFilters: { key: string; label: string }[] = [
{ key: '', label: 'All alerts' },
{ key: 'RAISED', label: 'Raised' },
- { key: 'UNDER_REVIEW', label: 'Under review' },
+ // No "Under review" chip: nothing sets that status now that reading a row no longer records anything, so the
+ // chip would sit at zero for good. The status itself is kept — alerts predating this change still carry it, and
+ // canReview still treats them as open — but it is not offered as a filter nobody can populate.
{ key: 'ESCALATED', label: 'Escalated' },
{ key: 'WHITELISTED', label: 'Whitelisted' }
];
activeStatus = '';
private statusCounts: Record = {};
+ /**
+ * The period being worked.
+ *
+ * A monitoring feed accumulates: last week's alerts sit under today's, and an officer asked "what did we decide
+ * today" has to read past them. The period filter is what turns the feed into a day's, a week's or a month's work
+ * — and it composes with the status chips, so Today + Escalated answers "what did we mark suspicious today".
+ */
+ readonly periodModes: { key: PeriodMode; label: string }[] = [
+ { key: 'all', label: 'All time' },
+ { key: 'day', label: 'Day wise' },
+ { key: 'week', label: 'Week wise' },
+ { key: 'month', label: 'Month wise' },
+ { key: 'year', label: 'Year wise' }
+ ];
+ periodMode: PeriodMode = 'all';
+
+ /** The chosen day / any day inside the chosen week / the chosen month / the chosen year. */
+ filterDay = '';
+ filterWeek = '';
+ filterMonth = '';
+ filterYear = '';
+
+ /**
+ * Narrows the table to the rules firing most often in the chosen period.
+ *
+ *
+ * A rule tripping repeatedly is a different question from one transaction tripping a rule: it is either a real
+ * pattern in the book, or a rule set too tight and filling the queue with noise. Either way the rules at the top of
+ * the count are the ones worth an officer's attention first, and the long tail below them can wait.
+ *
+ *
+ * Null shows every rule. The options are deliberately small — a "top 10" that lists 40 rules answers nothing.
+ */
+ readonly topRuleOptions: (number | null)[] = [
+ null,
+ 10,
+ 20,
+ 50
+ ];
+ topRules: number | null = null;
+
+ setTopRules(n: number | null): void {
+ this.topRules = n;
+ this.applyStatusFilter(true);
+ }
+
+ /**
+ * The rule codes to keep, most-triggered first, or null when every rule is in view.
+ *
+ *
+ * Counted over the period and status already chosen rather than over the whole feed, so "top 10 this month" means
+ * the ten busiest rules of that month. Ties are broken on the rule code so the same set comes back each time
+ * instead of shifting between polls.
+ */
+ private topRuleCodes(pool: any[]): Set | null {
+ if (this.topRules === null) {
+ return null;
+ }
+ const counts = new Map();
+ for (const a of pool) {
+ const code = a?.ruleCode;
+ if (code) {
+ counts.set(code, (counts.get(code) ?? 0) + 1);
+ }
+ }
+ if (counts.size <= this.topRules) {
+ return null;
+ }
+ const ranked = Array.from(counts.entries())
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+ .slice(0, this.topRules)
+ .map(([code]) => code);
+ return new Set(ranked);
+ }
+
+ /** How many distinct rules the current view holds, so the chip can say what it is narrowing. */
+ get distinctRuleCount(): number {
+ return new Set(this.alerts.data.map((a) => a.ruleCode).filter((c) => c)).size;
+ }
+
+ /**
+ * The rules in view with how often each fired, busiest first.
+ *
+ *
+ * The point of ranking rules is to see which ones are driving the queue, and a count per rule is that answer.
+ * Without it "Top 10" narrows the table and leaves the officer to work out what it kept.
+ */
+ get ruleFrequency(): { code: string; count: number }[] {
+ const counts = new Map();
+ for (const a of this.alerts.data) {
+ if (a?.ruleCode) {
+ counts.set(a.ruleCode, (counts.get(a.ruleCode) ?? 0) + 1);
+ }
+ }
+ return Array.from(counts.entries())
+ .map(
+ ([
+ code,
+ count
+ ]) => ({ code, count })
+ )
+ .sort((a, b) => b.count - a.count || a.code.localeCompare(b.code));
+ }
+
+ setPeriodMode(mode: PeriodMode): void {
+ this.periodMode = mode;
+ // Seeded with today the first time a mode is picked, so choosing "Month wise" shows this month rather than an
+ // empty field the officer has to fill before anything happens.
+ const now = new Date();
+ const iso = this.isoOf(now);
+ if (mode === 'day' && !this.filterDay) {
+ this.filterDay = iso;
+ }
+ if (mode === 'week' && !this.filterWeek) {
+ this.filterWeek = iso;
+ }
+ if (mode === 'month' && !this.filterMonth) {
+ this.filterMonth = iso.slice(0, 7);
+ }
+ if (mode === 'year' && !this.filterYear) {
+ this.filterYear = String(now.getFullYear());
+ }
+ this.applyStatusFilter(true);
+ }
+
+ onPeriodChange(): void {
+ this.applyStatusFilter(true);
+ }
+
+ /** The years the feed actually covers, newest first, so the picker offers nothing empty. */
+ get availableYears(): string[] {
+ const years = new Set();
+ for (const a of this.allAlerts) {
+ if (a.raisedOnDate) {
+ years.add(String(a.raisedOnDate.getFullYear()));
+ }
+ }
+ const list = Array.from(years).sort().reverse();
+ return list.length ? list : [String(new Date().getFullYear())];
+ }
+
+ /** Reads back the resolved period, so the officer can see what "week wise" actually resolved to. */
+ get periodLabel(): string {
+ const range = this.periodRange();
+ if (!range) {
+ return '';
+ }
+ return range.from === range.to ? range.from : `${range.from} to ${range.to}`;
+ }
+
+ private isoOf(d: Date): string {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+ }
+
+ /**
+ * The chosen period as an inclusive from/to, or null for all time.
+ *
+ * Week runs Monday to Sunday. Built from local date parts rather than by parsing to UTC: an alert raised late in
+ * the evening is on that evening's date for the officer looking at it, and a UTC round-trip would move it.
+ */
+ private periodRange(): { from: string; to: string } | null {
+ switch (this.periodMode) {
+ case 'day':
+ return this.filterDay ? { from: this.filterDay, to: this.filterDay } : null;
+ case 'week': {
+ if (!this.filterWeek) {
+ return null;
+ }
+ const [
+ y,
+ m,
+ d
+ ] = this.filterWeek.split('-').map(Number);
+ const picked = new Date(y, m - 1, d);
+ const backToMonday = picked.getDay() === 0 ? 6 : picked.getDay() - 1;
+ const monday = new Date(y, m - 1, d - backToMonday);
+ const sunday = new Date(y, m - 1, d - backToMonday + 6);
+ return { from: this.isoOf(monday), to: this.isoOf(sunday) };
+ }
+ case 'month': {
+ if (!this.filterMonth) {
+ return null;
+ }
+ const [
+ y,
+ m
+ ] = this.filterMonth.split('-').map(Number);
+ // Day 0 of the next month is the last day of this one, which handles February and leap years without a table.
+ return { from: `${this.filterMonth}-01`, to: this.isoOf(new Date(y, m, 0)) };
+ }
+ case 'year':
+ return this.filterYear ? { from: `${this.filterYear}-01-01`, to: `${this.filterYear}-12-31` } : null;
+ default:
+ return null;
+ }
+ }
+
+ /** Whether an alert was raised inside the chosen period. */
+ private inActivePeriod(alert: any): boolean {
+ const range = this.periodRange();
+ if (!range) {
+ return true;
+ }
+ const d: Date | null = alert?.raisedOnDate;
+ if (!d) {
+ return false;
+ }
+ const iso = this.isoOf(d);
+ return iso >= range.from && iso <= range.to;
+ }
+
+ /**
+ * The severity tile currently narrowing the table: 1 Low, 2 Medium, 3 High, 0 unclassified, or
+ * null for all. Independent of {@link activeStatus} — the two compose, so an officer can ask for
+ * the high-severity alerts that are still open.
+ */
+ activeSeverity: number | null = null;
+
/**
* Severity mix of the flagged transactions currently in the table. riskCategory is the severity
* of the rule that fired, resolved server-side from the originating catalog entry — the same
* field the Rules Master tab shows in its Risk Category column.
*/
- riskSummary: { label: string; count: number; cls: string }[] = [];
+ riskSummary: { key: number; label: string; count: number; cls: string }[] = [];
/** Alerts whose rule has no catalog ancestor (MANUAL_REFERRAL), so no severity to report. */
unclassifiedCount = 0;
@@ -106,6 +628,18 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
@ViewChild(MatPaginator) paginator!: MatPaginator;
+ /**
+ * Binds the paginator as soon as the view exists.
+ *
+ *
+ * The first poll can return before the view has initialised, and until this the data source was
+ * only ever wired up as a side effect of a later filter pass — so the table could render every
+ * alert unpaginated, then jump once the next poll arrived.
+ */
+ ngAfterViewInit(): void {
+ this.alerts.paginator = this.paginator;
+ }
+
ngOnInit(): void {
this.fetch();
timer(AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS, AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS)
@@ -158,7 +692,22 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
setStatus(key: string): void {
this.activeStatus = key;
- this.applyStatusFilter();
+ this.applyStatusFilter(true);
+ }
+
+ /** Clicking the tile already in force clears it, so the same click both narrows and undoes. */
+ setSeverity(band: number): void {
+ this.activeSeverity = this.activeSeverity === band ? null : band;
+ this.applyStatusFilter(true);
+ }
+
+ isSeverityActive(band: number): boolean {
+ return this.activeSeverity === band;
+ }
+
+ clearSeverity(): void {
+ this.activeSeverity = null;
+ this.applyStatusFilter(true);
}
statusCount(key: string): number {
@@ -203,58 +752,202 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
this.allAlerts = list;
this.recomputeStatusCounts();
this.applyStatusFilter();
+ this.pruneSelection();
this.lastUpdated = new Date();
}
- private applyStatusFilter(): void {
- this.alerts.data = this.activeStatus
- ? this.allAlerts.filter((a) => a.status === this.activeStatus)
- : this.allAlerts;
+ /**
+ * @param resetPage
+ * true only when the user changed a filter. A poll must not reset the page: the feed
+ * refreshes every few seconds, and an officer reading page 3 would be thrown back to
+ * page 1 each time. Nor may it touch the paginator before the data source has bound to
+ * it — doing that on the first pass left pageSize at 0 and emptied the table.
+ */
+ private applyStatusFilter(resetPage = false): void {
+ // The day narrows everything else: the status chips and the severity tiles are asking "of what is in view", and
+ // an officer who has picked a day expects their counts to describe that day rather than the whole feed.
+ const byDay = this.allAlerts.filter((a) => this.inActivePeriod(a));
+ const byStatus = this.activeStatus ? byDay.filter((a) => this.matchesStatus(a, this.activeStatus)) : byDay;
+ const bySeverity =
+ this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity);
+ // Ranked last, over what the other filters left: "top 10 this month" is the ten busiest rules of that month, not
+ // the ten busiest overall then filtered down to a handful.
+ const keep = this.topRuleCodes(bySeverity);
+ this.alerts.data = keep ? bySeverity.filter((a) => keep.has(a.ruleCode)) : bySeverity;
if (!this.alerts.paginator && this.paginator) {
this.alerts.paginator = this.paginator;
}
- this.recomputeRiskSummary();
+ if (resetPage && this.alerts.paginator) {
+ // Otherwise a filter applied while on page 2 leaves the table blank on a one-page result.
+ this.alerts.paginator.firstPage();
+ }
+ // Counted before the severity filter, so every tile keeps its real figure and stays clickable.
+ // Counting the filtered rows would zero the other tiles the moment one was picked, leaving no
+ // way back to them.
+ this.recomputeRiskSummary(byStatus);
}
/**
* Counted over the rows the status filter leaves visible, not the whole set — a summary that
* disagreed with the table underneath it would be worse than no summary.
*/
- private recomputeRiskSummary(): void {
- const pool = this.alerts.data;
+ private recomputeRiskSummary(pool: any[]): void {
const tiers = [
{ key: 3, label: 'High Risk', cls: 'risk-high' },
{ key: 2, label: 'Medium Risk', cls: 'risk-medium' },
{ key: 1, label: 'Low Risk', cls: 'risk-low' }
];
this.riskSummary = tiers.map((t) => ({
+ key: t.key,
label: t.label,
cls: t.cls,
- count: pool.filter((a) => Number(a.riskCategory) === t.key).length
+ count: pool.filter((a) => this.severityBand(a) === t.key).length
}));
- // Anything outside 1-3, including null, so the tiles always reconcile to the total.
- this.unclassifiedCount = pool.filter(
- (a) => ![
- 1,
- 2,
- 3
- ].includes(Number(a.riskCategory))
- ).length;
+ // Band 0 is everything ungraded, so the tiles always reconcile to the total.
+ this.unclassifiedCount = pool.filter((a) => this.severityBand(a) === 0).length;
+ }
+
+ /**
+ * Everything the status filter leaves, whatever severity tile is in force — so the tiles still
+ * add up to the total while one of them is narrowing the table, and the total reads as the way
+ * back to all of them.
+ */
+ exporting = false;
+
+ /**
+ * Exports exactly what the table is showing.
+ *
+ *
+ * Built from `alerts.data` rather than from the full feed, so the period, status and severity an officer chose are
+ * the period, status and severity in the file. A workbook that quietly held more than the screen did would be
+ * worse than none: the figures in it would not reconcile with the report it came from, and an officer sending it
+ * on has no way to tell.
+ *
+ *
+ * The filters are written into the subtitle and the file name for the same reason — a sheet that says "10 alerts"
+ * with nothing about which month it covers cannot be checked later by anyone, including the person who made it.
+ */
+ async exportExcel(): Promise {
+ const rows = this.alerts.data;
+ if (!rows.length || this.exporting) {
+ return;
+ }
+ this.exporting = true;
+ try {
+ const scope = [
+ this.periodLabel ? `Period: ${this.periodLabel}` : 'Period: all time',
+ `Status: ${this.activeStatus ? this.statusLabel(this.activeStatus) : 'all'}`,
+ `Severity: ${this.activeSeverity === null ? 'all' : this.severityLabel(this.activeSeverity)}`,
+ `Rules: ${this.topRules === null ? 'all' : `top ${this.topRules} by frequency`}`
+ ].join(' · ');
+
+ await exportWorkbook(
+ [
+ {
+ sheetName: 'Flagged Transactions',
+ title: 'AML — Flagged Transactions',
+ subtitle: `${scope} · ${rows.length} alert${rows.length === 1 ? '' : 's'} · generated ${new Date().toLocaleString('en-IN')}`,
+ headers: [
+ 'Alert ID',
+ 'Raised On',
+ 'Customer',
+ 'Customer ID',
+ 'Transaction',
+ 'Txn ID',
+ 'Governing Rule',
+ 'Rule Description',
+ 'All Rules Matched',
+ 'Severity',
+ 'Amount',
+ 'Status',
+ 'Reviewed By',
+ 'Review Notes'
+ ],
+ rows: rows.map((a) => [
+ a.id ?? null,
+ a.raisedOnDate ? a.raisedOnDate.toLocaleString('en-IN') : '',
+ a.clientDisplayName || '',
+ a.clientId ?? null,
+ a.transactionType || '',
+ a.transactionId ?? null,
+ a.ruleCode || '',
+ a.ruleDescription || '',
+ // Every rule the transaction tripped, not only the one it reports under: the governing rule alone can
+ // hide a high-severity finding that a reader of the sheet would need.
+ this.matchedRules(a)
+ .map((r) => r.ruleCode)
+ .join(', '),
+ this.severityLabel(this.severityBand(a)),
+ Number(a.amount) || 0,
+ a.status || '',
+ a.reviewedByUsername || '',
+ a.reviewNotes || ''
+ ]),
+ widths: [
+ 9,
+ 20,
+ 24,
+ 12,
+ 12,
+ 10,
+ 30,
+ 38,
+ 44,
+ 11,
+ 15,
+ 14,
+ 14,
+ 50
+ ]
+ }
+ ],
+ `AML-Flagged-${this.periodLabel || 'all-time'}`
+ );
+ } finally {
+ this.exporting = false;
+ }
+ }
+
+ private statusLabel(key: string): string {
+ return this.statusFilters.find((s) => s.key === key)?.label ?? key;
}
get summaryTotal(): number {
- return this.alerts.data.length;
+ const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
+ return this.activeStatus ? pool.filter((a) => this.matchesStatus(a, this.activeStatus)).length : pool.length;
}
private recomputeStatusCounts(): void {
- this.statusCounts = { '': this.allAlerts.length };
+ // Counted within the chosen day, so "Escalated 1" means one escalated that day rather than one in the whole feed.
+ // A chip whose figure describes a different set from the table under it is worse than no figure.
+ const pool = this.allAlerts.filter((a) => this.inActivePeriod(a));
+ this.statusCounts = { '': pool.length };
for (const s of this.statusFilters) {
if (s.key) {
- this.statusCounts[s.key] = this.allAlerts.filter((a) => a.status === s.key).length;
+ this.statusCounts[s.key] = pool.filter((a) => this.matchesStatus(a, s.key)).length;
}
}
}
+ /**
+ * Whether an alert belongs under a status chip.
+ *
+ * Not an equality test, deliberately. Matching the chip's key exactly meant any status the chip list did not name
+ * belonged to no chip at all while still counting toward "All alerts" — so an alert could sit in the feed, need a
+ * decision, and appear nowhere an officer looks for work. That is what UNDER_REVIEW did here: five undecided
+ * alerts, the largest ₹18,00,000, behind a pending figure that read 1.
+ *
+ * Pending is therefore defined by what has not been decided rather than by one status name. Any status that is
+ * neither whitelisted nor escalated surfaces under Raised on its own, which is the safe direction to fail — an
+ * alert shown as work that turns out to be finished costs a glance; one hidden costs a filing.
+ */
+ private matchesStatus(alert: any, key: string): boolean {
+ if (key === 'RAISED') {
+ return alert?.status !== 'WHITELISTED' && alert?.status !== 'ESCALATED';
+ }
+ return alert?.status === key;
+ }
+
statusClass(status: string): string {
return 'alert-status status-' + (status || 'raised').toLowerCase();
}
@@ -302,7 +995,26 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
* opening a second STR case — this gate is what prevents it.
*/
canReview(alert: any): boolean {
- return alert?.status === 'RAISED' || alert?.status === 'UNDER_REVIEW';
+ if (alert?.status !== 'RAISED' && alert?.status !== 'UNDER_REVIEW') {
+ return false;
+ }
+ // An alert another officer has open is theirs until they decide it or hand it back. The endpoint refuses it
+ // anyway; hiding the buttons is what stops an officer working a case they will not be allowed to close.
+ return !this.heldByAnother(alert);
+ }
+
+ /** True when this alert is under review by somebody other than the signed-in officer. */
+ heldByAnother(alert: any): boolean {
+ return (
+ alert?.status === 'UNDER_REVIEW' &&
+ !!alert?.reviewedByUsername &&
+ alert.reviewedByUsername !== this.currentUsername
+ );
+ }
+
+ /** Who has the alert, for the message shown in place of the action buttons. */
+ heldBy(alert: any): string {
+ return alert?.reviewedByUsername || 'another officer';
}
review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
@@ -330,4 +1042,73 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
this.fetch();
});
}
+
+ /**
+ * Applies one disposition to every ticked alert.
+ *
+ *
+ * Each still goes through the review dialog's own requirements - a reason, and for a whitelist
+ * the evidence behind it - asked once and recorded against every alert in the batch. That is the
+ * point of the batch: the officer is making one decision about one kind of activity, not the same
+ * decision twenty times.
+ */
+ /**
+ * Applies one disposition to the rules ticked inside the open pane.
+ *
+ *
+ * The alert's own status is left to the server, which derives it from what remains: whitelisting five of six rules
+ * leaves the case open on the sixth rather than closing it.
+ */
+ reviewSelectedRules(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
+ const codes = Array.from(this.selectedRuleCodes);
+ if (!codes.length) {
+ return;
+ }
+ this.dialog
+ .open(AmlReviewAlertDialogComponent, {
+ width: '560px',
+ maxWidth: '95vw',
+ autoFocus: false,
+ disableClose: true,
+ data: { alert, disposition, ruleCodes: codes }
+ })
+ .afterClosed()
+ .subscribe((result: string | undefined) => {
+ if (!result) {
+ return;
+ }
+ this.alertService.alert({
+ type: disposition === 'WHITELIST' ? 'Rules Whitelisted' : 'Rules Escalated',
+ message: `${codes.length} rule${codes.length > 1 ? 's' : ''} on ${alert.transactionType} #${alert.transactionId} ${
+ disposition === 'WHITELIST' ? 'cleared' : 'escalated'
+ }.`
+ });
+ this.selectedRuleCodes.clear();
+ // The alert's status may have changed with the last rule decided, so re-read rather than patching locally.
+ this.fetch();
+ });
+ }
+
+ reviewSelected(disposition: 'WHITELIST' | 'ESCALATE'): void {
+ const batch = this.selectedAlerts();
+ if (!batch.length) {
+ return;
+ }
+ this.dialog
+ .open(AmlBulkReviewDialogComponent, {
+ width: '620px',
+ maxWidth: '95vw',
+ autoFocus: false,
+ disableClose: true,
+ data: { alerts: batch, disposition }
+ })
+ .afterClosed()
+ .subscribe((done: boolean | undefined) => {
+ if (!done) {
+ return;
+ }
+ this.selectedIds.clear();
+ this.fetch();
+ });
+ }
}
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts
index c5bc66317..beddd4527 100644
--- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts
+++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts
@@ -31,6 +31,11 @@ import { AmlService } from '../../../aml/aml.service';
export interface AmlReviewDialogData {
alert: any;
disposition: 'WHITELIST' | 'ESCALATE';
+ /**
+ * The rules being decided, when the officer picked some inside the expanded pane rather than acting on the whole
+ * transaction. Absent means the decision covers the alert and every rule under it.
+ */
+ ruleCodes?: string[];
}
/**
@@ -162,6 +167,13 @@ export class AmlReviewAlertDialogComponent {
}
get title(): string {
+ // Named differently when only some of the transaction's rules are being decided, so the officer is not told they
+ // are clearing a transaction when five of its six rules stay open.
+ const n = this.data.ruleCodes?.length ?? 0;
+ if (n > 0) {
+ const subject = n === 1 ? 'Rule' : `${n} Rules`;
+ return this.isWhitelist ? `Whitelist ${subject}` : `Mark ${subject} as Suspicious`;
+ }
return this.isWhitelist ? 'Whitelist Transaction' : 'Mark as Suspicious';
}
@@ -181,7 +193,17 @@ export class AmlReviewAlertDialogComponent {
this.submitting = true;
this.errorMessage = '';
- this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes).subscribe({
+ // Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence
+ // requirements are the same either way, which is why this dialog serves both.
+ const request = this.data.ruleCodes?.length
+ ? this.amlService.reviewAlertRules(
+ this.data.alert.id,
+ this.data.ruleCodes,
+ this.data.disposition,
+ this.composedNotes
+ )
+ : this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes);
+ request.subscribe({
next: () => {
this.submitting = false;
this.dialogRef.close(this.data.disposition);
diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts
index 76f120463..4a6fe1a06 100644
--- a/src/app/reports/reports-routing.module.ts
+++ b/src/app/reports/reports-routing.module.ts
@@ -93,6 +93,11 @@ const routes: Routes = [
loadComponent: () =>
import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent)
},
+ {
+ path: 'bor',
+ data: { title: 'BOR Register — Share Capital', breadcrumb: 'BOR Register' },
+ loadComponent: () => import('../board-resolution/bor-report.component').then((m) => m.BorReportComponent)
+ },
{
path: ':filter',
data: { routeParamBreadcrumb: 'filter' },
diff --git a/src/app/risk-review/risk-change-approvals.component.ts b/src/app/risk-review/risk-change-approvals.component.ts
new file mode 100644
index 000000000..677928e01
--- /dev/null
+++ b/src/app/risk-review/risk-change-approvals.component.ts
@@ -0,0 +1,401 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
+import { MatButtonModule } from '@angular/material/button';
+import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+
+import { AmlService } from 'app/aml/aml.service';
+
+const CATEGORY_LABEL: Record = { 1: 'Low', 2: 'Medium', 3: 'High' };
+
+/**
+ * The approval queue for risk-category changes raised from the Risk Review Report.
+ *
+ *
+ * Approving here moves the customer's KYC rating and their re-KYC due date together, so the row
+ * shows where the rating is going from and to, and why the reviewer asked - the checker is deciding
+ * how closely the bank watches that customer, not clearing a queue item.
+ */
+@Component({
+ selector: 'mifosx-risk-change-approvals',
+ standalone: true,
+ imports: [
+ CommonModule,
+ MatButtonModule,
+ MatDialogModule,
+ MatSnackBarModule
+ ],
+ template: `
+
+
+
+
Risk Category Approvals
+
+ Changes to a customer's KYC risk category, raised from the Risk Review Report. Approving moves the rating
+ and the re-KYC due date together, and writes the reason into the customer's risk history.
+
+
+
+
+
+
+ {{ s === '' ? 'All' : s }} ({{ countOf(s) }})
+
+
+ {{ data.request.clientName }} stays on their current rating. The reason is shown to the reviewer who raised it.
+
+
+
+
+ Cancel
+
+ Reject
+
+
+ `,
+ styles: [
+ `
+ .full {
+ width: 100%;
+ }
+ .hint {
+ font-size: 13px;
+ color: #64748b;
+ margin: 0 0 12px;
+ line-height: 1.5;
+ }
+ `
+ ]
+})
+export class RejectRiskChangeDialogComponent {
+ private fb = inject(FormBuilder);
+ private amlService = inject(AmlService);
+ private snackBar = inject(MatSnackBar);
+ dialogRef = inject(MatDialogRef);
+
+ data: { request: any } = inject(MAT_DIALOG_DATA);
+ form: FormGroup;
+ busy = false;
+
+ constructor() {
+ this.form = this.fb.group({ rejectionReason: [
+ '',
+ Validators.required
+ ] });
+ }
+
+ submit(): void {
+ if (this.form.invalid) {
+ return;
+ }
+ this.busy = true;
+ this.amlService.rejectRiskChange(this.data.request.id, this.form.value.rejectionReason).subscribe({
+ next: () => {
+ this.busy = false;
+ this.snackBar.open('Risk category change rejected', 'Close', { duration: 3000 });
+ this.dialogRef.close(true);
+ },
+ error: (err: any) => {
+ this.busy = false;
+ this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 6000 });
+ }
+ });
+ }
+}
diff --git a/src/app/risk-review/risk-change-dialog.component.ts b/src/app/risk-review/risk-change-dialog.component.ts
new file mode 100644
index 000000000..2cd8c7915
--- /dev/null
+++ b/src/app/risk-review/risk-change-dialog.component.ts
@@ -0,0 +1,222 @@
+/**
+ * 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, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
+import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatSelectModule } from '@angular/material/select';
+import { MatSnackBar } from '@angular/material/snack-bar';
+
+import { AmlService } from 'app/aml/aml.service';
+
+export interface RiskChangeDialogData {
+ clientId: number;
+ customerName: string;
+ /** 1 Low, 2 Medium, 3 High; null where the customer carries no rating yet. */
+ currentCategory: number | null;
+ /** What the report's own computation says the rating should be, used as the default. */
+ recommendedCategory: number | null;
+ /** The one-line explanation the report already shows against the row. */
+ recommendationReason: string;
+}
+
+/**
+ * Raises a change to a customer's KYC risk category for approval.
+ *
+ *
+ * The rating governs how often the customer is re-KYC'd and what AML thresholds they are measured
+ * against, so it is not something one officer moves on their own. The dialog collects what it
+ * should become and why; a checker applies it.
+ */
+@Component({
+ selector: 'mifosx-risk-change-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ ReactiveFormsModule,
+ MatDialogModule,
+ MatButtonModule,
+ MatFormFieldModule,
+ MatInputModule,
+ MatSelectModule
+ ],
+ template: `
+
Change Risk Category
+
+
+
+ Customer
+ {{ data.customerName }}
+
+
+ Rating on file
+ {{ labelOf(data.currentCategory) }}
+
+
+
+
+
+ Why this came up {{ data.recommendationReason }}
+
+
+
+
+
+ The rating does not move now. It goes to a checker, and on approval the customer's KYC record and re-KYC due
+ date are updated together.
+
+
+
{{ errorMessage }}
+
+
+ Cancel
+
+ {{ submitting ? 'Sending…' : 'Send for Approval' }}
+
+
+ `,
+ styles: [
+ `
+ .rc-summary {
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ padding: 12px 14px;
+ margin-bottom: 14px;
+ background: #f8fafc;
+ }
+ .rc-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 16px;
+ padding: 3px 0;
+ }
+ .rc-label {
+ color: #64748b;
+ font-size: 12.5px;
+ }
+ .rc-value {
+ color: #1e293b;
+ font-size: 13.5px;
+ }
+ .rc-reason {
+ font-size: 12.5px;
+ line-height: 1.5;
+ color: #334155;
+ margin: 0 0 14px;
+ }
+ .rc-form {
+ display: flex;
+ flex-direction: column;
+ }
+ .rc-full {
+ width: 100%;
+ }
+ .rc-effect {
+ font-size: 12.5px;
+ line-height: 1.55;
+ color: #1e3a8a;
+ background: #eff6ff;
+ border: 1px solid #bfdbfe;
+ border-radius: 6px;
+ padding: 10px 12px;
+ margin: 4px 0 0;
+ }
+ .rc-error {
+ margin: 12px 0 0;
+ padding: 10px 12px;
+ border: 1px solid #fecaca;
+ border-radius: 6px;
+ background: #fef2f2;
+ color: #b91c1c;
+ font-size: 12.5px;
+ }
+ `
+ ]
+})
+export class RiskChangeDialogComponent {
+ private fb = inject(FormBuilder);
+ private amlService = inject(AmlService);
+ private snackBar = inject(MatSnackBar);
+ dialogRef = inject(MatDialogRef);
+
+ form: FormGroup;
+ submitting = false;
+ errorMessage = '';
+
+ constructor(@Inject(MAT_DIALOG_DATA) public data: RiskChangeDialogData) {
+ this.form = this.fb.group({
+ // Defaulted to what the report recommends, but left editable: the reviewer has seen the
+ // customer and the computation has not.
+ requestedCategory: [
+ data.recommendedCategory ?? null,
+ Validators.required
+ ],
+ reason: [
+ '',
+ Validators.required
+ ]
+ });
+ }
+
+ labelOf(category: number | null): string {
+ return category === 1 ? 'Low' : category === 2 ? 'Medium' : category === 3 ? 'High' : 'Unrated';
+ }
+
+ submit(): void {
+ if (this.form.invalid) {
+ this.form.markAllAsTouched();
+ return;
+ }
+ this.submitting = true;
+ this.errorMessage = '';
+ const v = this.form.value;
+ this.amlService.requestRiskChange(this.data.clientId, v.requestedCategory, (v.reason || '').trim()).subscribe({
+ next: () => {
+ this.submitting = false;
+ this.snackBar.open(
+ `Risk change to ${this.labelOf(v.requestedCategory)} sent for approval — ${this.data.customerName}`,
+ 'Close',
+ { duration: 5000 }
+ );
+ this.dialogRef.close(true);
+ },
+ error: (err: any) => {
+ this.submitting = false;
+ this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Could not send the request.';
+ }
+ });
+ }
+}
diff --git a/src/app/risk-review/risk-review-data.ts b/src/app/risk-review/risk-review-data.ts
index 633de824d..fea25ea07 100644
--- a/src/app/risk-review/risk-review-data.ts
+++ b/src/app/risk-review/risk-review-data.ts
@@ -372,13 +372,29 @@ export const MATERIALITY = {
* Rules whose seriousness does not depend on the amount. A sanctions match or a
* deliberate structuring pattern is grave at any value, so activity being small
* is not a reason to bring the rating down.
+ *
+ * Matched as prefixes, not as whole codes. The rule set names these by variant —
+ * STRUCTURING_BELOW_50K, STRUCTURING_BELOW_10L, PEP_ACCOUNT_MONITORING — so an
+ * exact-match Set never matched any of them and the protection was dead: a
+ * customer caught structuring could be recommended for downgrade purely because
+ * the amounts involved were small, which is the one case structuring exists to
+ * catch.
*/
-export const NON_MITIGABLE_RULES = new Set([
- 'SANCTIONS_HIT',
+export const NON_MITIGABLE_RULE_PREFIXES = [
+ 'SANCTIONS',
'PEP',
'STRUCTURING',
- 'HIGH_RISK_COUNTRY'
-]);
+ 'STRUCTURED',
+ 'HIGH_RISK_COUNTRY',
+ 'ADVERSE_MEDIA',
+ 'WILFUL_DEFAULTER'
+];
+
+/** Whether a rule code names activity that stays grave however small the amounts are. */
+export function isNonMitigableRule(ruleCode: string): boolean {
+ const code = (ruleCode || '').toUpperCase();
+ return NON_MITIGABLE_RULE_PREFIXES.some((prefix) => code.startsWith(prefix));
+}
/** Shape of one element of GET /aml/alerts. */
export interface LiveAmlAlert {
diff --git a/src/app/risk-review/risk-review-report.component.html b/src/app/risk-review/risk-review-report.component.html
index 503cb0dad..9bef269f2 100644
--- a/src/app/risk-review/risk-review-report.component.html
+++ b/src/app/risk-review/risk-review-report.component.html
@@ -304,7 +304,9 @@
reason sits under it rather than under the button, because it explains
this conclusion and not the act of opening the KYC screen. -->
diff --git a/src/app/risk-review/risk-review-report.component.scss b/src/app/risk-review/risk-review-report.component.scss
index c4f9b6066..db236d881 100644
--- a/src/app/risk-review/risk-review-report.component.scss
+++ b/src/app/risk-review/risk-review-report.component.scss
@@ -937,6 +937,13 @@
background: var(--r-g100);
color: var(--r-g500);
}
+
+ /* An unrated customer reads neither as settled nor as a movement: there is no rating to move. Amber says the row
+ is outstanding without claiming the customer is a concern — the gap is the bank's, not theirs. */
+ &--unrated {
+ background: var(--r-amber-l, #fef3c7);
+ color: var(--r-amber, #b45309);
+ }
}
.rr-move-why {
diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts
index efa8d1880..ee9d5f2b9 100644
--- a/src/app/risk-review/risk-review-report.component.ts
+++ b/src/app/risk-review/risk-review-report.component.ts
@@ -10,6 +10,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
+import { MatDialog } from '@angular/material/dialog';
import { forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
@@ -38,12 +39,14 @@ import {
RiskLevel,
channelLabel,
mapLiveAlert,
- riskFromCategory
+ riskFromCategory,
+ riskRank
} from './risk-review-data';
import {
DateRange,
RatingDirection,
RiskAlertView,
+ BehaviourWindowByClient,
RiskCustomerRow,
RiskReviewFilter,
RiskReviewModel,
@@ -53,6 +56,7 @@ import {
monthRange,
weekRange
} from './risk-review.compute';
+import { RiskChangeDialogComponent, RiskChangeDialogData } from './risk-change-dialog.component';
type PeriodMode = 'day' | 'week' | 'month' | 'range';
type ViewMode = 'customer' | 'alert' | 'txn';
@@ -109,6 +113,7 @@ export class RiskReviewReportComponent implements OnInit {
private clients = inject(ClientsService);
private scope = inject(BranchScopeService);
private router = inject(Router);
+ private dialog = inject(MatDialog);
readonly period = GL_PERIOD;
readonly branches = GL_BRANCHES;
@@ -181,21 +186,37 @@ export class RiskReviewReportComponent implements OnInit {
// ── Loading ────────────────────────────────────────────────────────────────
+ /**
+ * The nightly job's verdict per customer, keyed on client id.
+ *
+ * Empty until the first load, and stays empty on a tenant where the job has not run - the report then falls back to
+ * its own per-alert reckoning rather than showing nothing.
+ */
+ private behaviourWindows: BehaviourWindowByClient = {};
+
load(): void {
this.loading = true;
- this.aml
- .getAlerts()
- .pipe(catchError(() => of([] as LiveAmlAlert[])))
- .subscribe((live) => {
- const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
- this.liveAlerts = rows;
- this.compose();
- if (!rows.length) {
- this.loading = false;
- return;
+ // Both in one pass: the windows decide what each row recommends, so composing before they arrive would show a
+ // recommendation the screen then silently replaced.
+ forkJoin({
+ alerts: this.aml.getAlerts().pipe(catchError(() => of([] as LiveAmlAlert[]))),
+ windows: this.aml.getBehaviourWindows().pipe(catchError(() => of([] as any[])))
+ }).subscribe(({ alerts: live, windows }) => {
+ this.behaviourWindows = {};
+ for (const w of windows || []) {
+ if (w?.clientId != null) {
+ this.behaviourWindows[w.clientId] = w;
}
- this.enrichCurrentRisk(rows);
- });
+ }
+ const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
+ this.liveAlerts = rows;
+ this.compose();
+ if (!rows.length) {
+ this.loading = false;
+ return;
+ }
+ this.enrichCurrentRisk(rows);
+ });
}
/**
@@ -341,9 +362,11 @@ export class RiskReviewReportComponent implements OnInit {
private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY };
private get model(): RiskReviewModel {
- const key = JSON.stringify(this.filter) + `|${this.alerts.length}`;
+ // The window count belongs in the key: the windows decide what each row recommends, and they arrive in the same
+ // load as the alerts. Keying on the alerts alone would serve the memo computed before they landed.
+ const key = JSON.stringify(this.filter) + `|${this.alerts.length}|${Object.keys(this.behaviourWindows).length}`;
if (key !== this.memoKey) {
- this.memo = buildRiskReview(this.alerts, this.filter);
+ this.memo = buildRiskReview(this.alerts, this.filter, this.behaviourWindows);
this.memoKey = key;
}
return this.memo;
@@ -369,10 +392,63 @@ export class RiskReviewReportComponent implements OnInit {
* a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is
* only a correction, and matching ratings need nothing at all.
*/
+ /**
+ * A report row for a customer the behaviour review named but no alert brought onto the report.
+ *
+ *
+ * The alert-derived fields are empty because there are none in the window, and that is the point being shown: the
+ * case rests on the period, not on anything that fired. The figures the verdict was drawn from travel on
+ * behaviourWindow, so the row can still explain itself.
+ */
+ private rowFromWindow(w: any): RiskCustomerRow {
+ const RANK: Record = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
+ const current = RANK[w.riskCategoryAtReview] ?? null;
+ const recommended = RANK[w.suggestedCategory] ?? current ?? 'LOW';
+ return {
+ clientId: w.clientId,
+ customerName: w.clientName || `Client ${w.clientId}`,
+ accountNo: w.accountNo || '',
+ branchCode: w.officeName || '',
+ currentRisk: current,
+ alerts: [],
+ alertCount: 0,
+ totalAmount: Number(w.creditTotal || 0) + Number(w.debitTotal || 0),
+ largestAmount: Number(w.largestTxn || 0),
+ proposedRisk: recommended,
+ recommendedRisk: recommended,
+ direction: w.suggestedDirection === 'UP' ? 'up' : w.suggestedDirection === 'DOWN' ? 'down' : 'same',
+ directionReason: w.suggestionReason || '',
+ ruleLabels: [],
+ firstAlertDate: '',
+ lastAlertDate: '',
+ openCount: 0,
+ escalated: false,
+ needsUpgrade: w.suggestedDirection === 'UP',
+ needsDowngrade: w.suggestedDirection === 'DOWN',
+ behaviourWindow: w
+ };
+ }
+
get customerGroups(): RiskCustomerGroup[] {
const rows = this.customerRows;
- const escalated = rows.filter((c) => c.escalated);
- const rest = rows.filter((c) => !c.escalated);
+ // An escalated customer whose rating still has to move belongs with the work, not with the cases that are waiting
+ // on someone else. An open STR holds a rating at High, and if the customer is not yet at High that is a change
+ // somebody has to apply - filing the STR does not make it. Only a customer whose rating already matches sits under
+ // the Principal Officer with nothing to do.
+ const escalated = rows.filter((c) => c.escalated && c.direction === 'same');
+ const rest = rows.filter((c) => !(c.escalated && c.direction === 'same'));
+
+ // Customers the nightly review says to move who raised no alert inside the filter window.
+ //
+ // The report is built from alerts, so a customer appears only once a rule has fired on them recently. That is the
+ // wrong test for a downgrade: the whole case for lowering a rating is that nothing has fired for a year, so the
+ // customer with the strongest case is precisely the one the alert feed has stopped mentioning. Without this they
+ // would sit in the behaviour table with a verdict nobody ever sees.
+ const onReport = new Set(rows.map((r) => r.clientId));
+ const offReport: RiskCustomerRow[] = Object.values(this.behaviourWindows)
+ .filter((w: any) => w?.clientId != null && !onReport.has(w.clientId))
+ .filter((w: any) => w.suggestedDirection === 'UP' || w.suggestedDirection === 'DOWN')
+ .map((w: any) => this.rowFromWindow(w));
const groups: RiskCustomerGroup[] = [
{
@@ -383,25 +459,42 @@ export class RiskReviewReportComponent implements OnInit {
rows: escalated
},
{
- key: 'up',
- title: 'Rating needs raising',
- hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.',
+ // Raising and lowering are one section, not two. The heading answers "is there anything to do about this
+ // customer", and which way the rating moves is already on every row - as a verdict, an arrow and a reason.
+ // Splitting them put the same answer in two places and left an officer reading two lists to find one day's
+ // work. Raises sort first within the section, because an under-rated customer is the more urgent gap.
+ key: 'change',
+ title: 'Change needed',
+ hint: 'Every condition for moving the rating is met. Apply the change; it goes to a checker for approval.',
tone: 'up',
- rows: rest.filter((c) => c.direction === 'up')
+ rows: [
+ ...rest.filter((c) => c.currentRisk && c.direction === 'up'),
+ ...offReport.filter((c) => c.direction === 'up'),
+ ...rest.filter((c) => c.currentRisk && c.direction === 'down'),
+ ...offReport.filter((c) => c.direction === 'down')
+ ]
},
{
- key: 'down',
- title: 'Rating can be lowered',
- hint: 'Activity no longer supports the rating on file.',
- tone: 'down',
- rows: rest.filter((c) => c.direction === 'down')
+ // Its own section, and above the watching list, because it is the opposite of that. An unrated customer whose
+ // money is moving has no rating for the behaviour to be compared against, so nothing here can say whether it
+ // is in keeping - the work is to give them one, and grouping them with the customers nothing need be done
+ // about buried the one action the report was asking for.
+ key: 'unrated',
+ title: 'Update risk',
+ hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.',
+ tone: 'up',
+ rows: rest.filter((c) => !c.currentRisk)
},
{
+ // Everything an alert put on the report that the policy will not move yet. "No change needed" read as though
+ // these customers were fine, which is not what it means: a customer with four open alerts sits here, and the
+ // reason is that the period does not yet carry enough to act on - not that there is nothing to look at.
+ // "Watching" says what the section is for, and the reason on each row says which condition fell short.
key: 'same',
- title: 'No change needed',
- hint: 'The rating on file already matches the behaviour. Listed for the record.',
+ title: 'Watching — not enough to act on',
+ hint: 'In view, but the period does not yet support moving the rating. The reason on each row says what is missing.',
tone: 'hold',
- rows: rest.filter((c) => c.direction === 'same')
+ rows: rest.filter((c) => c.currentRisk && c.direction === 'same')
}
];
@@ -580,6 +673,67 @@ export class RiskReviewReportComponent implements OnInit {
]);
}
+ /**
+ * Sends a case that is with the Principal Officer to the STR queue.
+ *
+ * The rating cannot be moved while an STR is open, so the useful thing to offer is the case
+ * itself. Sending the reviewer to the customer's KYC record instead made them land on a screen
+ * where the one action they might take is the one thing they must not do yet.
+ */
+ openStrCase(c: RiskCustomerRow, event: Event): void {
+ event.stopPropagation();
+ this.router.navigate(['/fiu'], { queryParams: { tab: 'str' } });
+ }
+
+ /**
+ * Whether this row's rating can be moved from here.
+ *
+ * A case with the Principal Officer is deliberately excluded: the rating is held where it is
+ * until the STR concludes, which is what the section heading on that group says. Offering the
+ * button there would invite a change nobody is in a position to make yet.
+ */
+ canChangeRisk(c: RiskCustomerRow): boolean {
+ return !c.escalated;
+ }
+
+ /**
+ * Raises a rating change for approval, rather than sending the reviewer to the KYC screen to make
+ * it themselves.
+ *
+ * The button used to navigate away: the reviewer left the report, found the customer, edited the
+ * record, and the change took effect on their say-so alone with no second pair of eyes and no
+ * record of why. The rating governs re-KYC frequency and AML thresholds, so it goes through a
+ * checker like every other decision of that weight.
+ */
+ changeRisk(c: RiskCustomerRow, event: Event): void {
+ event.stopPropagation();
+ if (!this.canChangeRisk(c)) {
+ return;
+ }
+ this.dialog
+ .open(RiskChangeDialogComponent, {
+ width: '520px',
+ maxWidth: '95vw',
+ disableClose: true,
+ data: {
+ clientId: c.clientId,
+ customerName: c.customerName,
+ currentCategory: riskRank(c.currentRisk) || null,
+ recommendedCategory: riskRank(c.recommendedRisk) || null,
+ recommendationReason: c.directionReason
+ } as RiskChangeDialogData
+ })
+ .afterClosed()
+ .subscribe((sent) => {
+ if (sent) {
+ // The rating has not moved yet - only a request exists - so the figures on screen are
+ // still correct. Reloaded anyway so a second click cannot raise a duplicate request the
+ // server would refuse.
+ this.load();
+ }
+ });
+ }
+
/**
* What the reviewer is going to the KYC screen to do.
*
@@ -594,25 +748,41 @@ export class RiskReviewReportComponent implements OnInit {
* change nobody is authorised to make yet.
*/
directionText(c: RiskCustomerRow): string {
- if (c.escalated) {
+ // Escalation is only the whole story when the rating already matches. An STR holds a customer at High, so one
+ // filed against a Medium-rated customer leaves a rating that still has to be moved - saying "held" there reports
+ // the case and hides the work, which is how this row came to sit under "Change needed" announcing that nothing
+ // needed doing.
+ if (c.escalated && c.direction === 'same') {
return 'Held — with Principal Officer';
}
- if (c.direction === 'up') {
- return `Raise to ${RISK_LABEL[c.recommendedRisk]}`;
+ // Both ends of the move, not just the destination. "Lower to Medium" reads as a verdict on its own and leaves the
+ // officer joining it to the rating column to see what is actually changing; "High → Medium" is the whole fact in
+ // one glance, and on an exported sheet it still says what happened without the column beside it.
+ if (c.direction === 'up' || c.direction === 'down') {
+ const from = c.currentRisk ? RISK_LABEL[c.currentRisk] : 'Unrated';
+ return `${from} → ${RISK_LABEL[c.recommendedRisk]}`;
}
- if (c.direction === 'down') {
- return `Lower to ${RISK_LABEL[c.recommendedRisk]}`;
- }
- return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record';
+ // An unrated customer is told what to do, not what was concluded. "No rating on record" states the gap and stops
+ // there, which on a worklist reads as though the row needs nothing - and it is in fact the row that needs the most.
+ return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'Rate this customer';
}
directionIcon(c: RiskCustomerRow): string {
- if (c.escalated) {
+ // Same test as directionText: a padlock says the case is out of the officer's hands, which is untrue while the
+ // rating still has to be moved.
+ if (c.escalated && c.direction === 'same') {
return 'lock';
}
- // 'check' rather than an equals sign: faEquals is not in the shared library, and an
- // unregistered icon throws at render and takes the whole row down with it.
- return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check';
+ if (c.direction === 'up') {
+ return 'arrow-up';
+ }
+ if (c.direction === 'down') {
+ return 'arrow-down';
+ }
+ // A tick claims the row is settled. An unrated customer is not settled, so it gets the mark that asks a question
+ // rather than the one that closes it. 'exclamation-circle' rather than plain 'exclamation': only the registered
+ // names resolve, and an unregistered icon throws at render and takes the whole row down with it.
+ return c.currentRisk ? 'check' : 'exclamation-circle';
}
/**
diff --git a/src/app/risk-review/risk-review.compute.ts b/src/app/risk-review/risk-review.compute.ts
index 67499a2bc..215fcac21 100644
--- a/src/app/risk-review/risk-review.compute.ts
+++ b/src/app/risk-review/risk-review.compute.ts
@@ -10,7 +10,7 @@ import {
AmlRuleMeta,
CTR_THRESHOLD,
MATERIALITY,
- NON_MITIGABLE_RULES,
+ isNonMitigableRule,
REVIEW_STATUS_META,
RISK_LABEL,
RiskAlertRow,
@@ -26,6 +26,17 @@ export type RatingDirection = 'up' | 'down' | 'same';
const RANK_TO_LEVEL: Record = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
+/**
+ * What has to have been seen before this screen's own reckoning will argue for lowering a rating.
+ *
+ * The nightly behaviour review is the real test — it reads a 270-day window, counts transactions rather than alerts,
+ * and applies the blocks. These are the floor for the fallback used until that job has run: deliberately modest,
+ * because the fallback sees only the alerts inside the report's date filter, but not absent, because judging on
+ * amounts alone let three small deposits in one month argue for taking a customer off Medium.
+ */
+const MIN_ALERTS_TO_LOWER = 5;
+const MIN_MONTHS_TO_LOWER = 3;
+
export interface DateRange {
from: string;
to: string;
@@ -109,6 +120,8 @@ export interface RiskCustomerRow {
needsUpgrade: boolean;
/** True when the rating on record is higher than the activity warrants. */
needsDowngrade: boolean;
+ /** The review period the recommendation was drawn from, where the nightly job has computed one. */
+ behaviourWindow?: BehaviourWindowByClient[number] | null;
}
export interface RiskReviewSummary {
@@ -155,17 +168,26 @@ function recommendRating(
largestAmount: number,
totalAmount: number,
escalated: boolean,
- ruleCodes: string[]
+ ruleCodes: string[],
+ alertCount: number,
+ distinctMonths: number
): { risk: RiskLevel; reason: string } {
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
- const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c));
+ const grave = ruleCodes.find((c) => isNonMitigableRule(c));
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
- const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
+
+ // Small amounts are not on their own a reason to lower a rating. A rating describes a period of behaviour, so
+ // bringing one down needs a period to have been observed: enough transactions to be a pattern, and spread over
+ // enough months that they are not one busy week. Judging on amounts alone let three small deposits in a single
+ // month argue for taking a customer off Medium — which is the same mistake as re-rating on one transaction, only
+ // three times over.
+ const enoughActivity = alertCount >= MIN_ALERTS_TO_LOWER && distinctMonths >= MIN_MONTHS_TO_LOWER;
+ const small = enoughActivity && largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
// What the rating should be. Amounts only ever pull the rules' bound down, and
// only where nothing weightier holds it up.
let risk: RiskLevel;
- let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate';
+ let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate' | 'thin';
if (escalated) {
risk = 'HIGH';
basis = 'escalated';
@@ -175,6 +197,12 @@ function recommendRating(
} else if (material) {
risk = implied;
basis = 'material';
+ } else if (!enoughActivity) {
+ // Too little observed to say anything about the rating either way, so it stands. Stating this rather than
+ // quietly leaving the rating alone is the point: an officer reading the row learns the period was too thin,
+ // not that the customer was examined and found unremarkable.
+ risk = currentRisk ?? implied;
+ basis = 'thin';
} else {
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
basis = small ? 'small' : 'moderate';
@@ -218,6 +246,27 @@ function recommendRating(
};
}
+ if (basis === 'thin') {
+ // A customer with no rating is not waiting for more evidence — there is no rating for evidence to move. Telling
+ // them the period is too thin, under a verdict that says "Rate this customer", asks for the work and argues
+ // against it in the same row. The thin-period test is about lowering a rating that exists.
+ if (!currentRisk) {
+ return {
+ risk,
+ reason: `Transacting with no rating on file — ${inr(totalAmount)} across ${alertCount} alert${
+ alertCount === 1 ? '' : 's'
+ }. Rate the customer; behaviour can only be judged against a rating.`
+ };
+ }
+ // Says which of the two tests fell short, because "not enough activity" leaves an officer unable to tell whether
+ // the customer needs watching for another month or another five.
+ const shortOn =
+ alertCount < MIN_ALERTS_TO_LOWER
+ ? `only ${alertCount} alert${alertCount === 1 ? '' : 's'} in the period, ${MIN_ALERTS_TO_LOWER} needed`
+ : `activity falls in ${distinctMonths} month${distinctMonths === 1 ? '' : 's'}, ${MIN_MONTHS_TO_LOWER} separate months needed`;
+ return { risk, reason: `Too little to re-rate on — ${shortOn}. ${rated}; it stands until more is seen.` };
+ }
+
// The rating on file is where the activity puts it. Saying so is the verdict — leaving it blank
// read as "not assessed yet" on screen, which is the opposite of what the review concluded.
return {
@@ -271,7 +320,36 @@ export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter)
* escalated, then rated below what the rules point at, then most alerts —
* come to the top of the worklist.
*/
-export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
+/**
+ * The behaviour window the nightly job computed for one customer, where there is one.
+ *
+ * Keyed on client id. A customer with no window - one the job has not seen yet, or a tenant where the job has never
+ * run - falls back to the per-alert reckoning below, so the report renders either way.
+ */
+export interface BehaviourWindowByClient {
+ [clientId: number]: {
+ clientId: number;
+ clientName: string | null;
+ accountNo: string | null;
+ officeName: string | null;
+ riskCategoryAtReview: number | null;
+ txnCount: number | null;
+ distinctMonths: number | null;
+ windowFrom: any;
+ windowTo: any;
+ creditTotal: number | null;
+ debitTotal: number | null;
+ largestTxn: number | null;
+ cashTotal: number | null;
+ alertCount: number | null;
+ strCaseCount: number | null;
+ suggestedDirection: string | null;
+ suggestedCategory: number | null;
+ suggestionReason: string | null;
+ };
+}
+
+export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindowByClient = {}): RiskCustomerRow[] {
const byClient = new Map();
for (const v of views) {
const list = byClient.get(v.clientId);
@@ -297,19 +375,59 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
const largestAmount = Math.max(...alerts.map((a) => a.amount));
const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED');
- const recommendation = recommendRating(
- proposedRank,
- currentRisk,
- largestAmount,
- totalAmount,
- escalated,
- alerts.map((a) => a.ruleCode)
- );
+ // The server's own reckoning, where the nightly job has produced one.
+ //
+ // It is the better answer, and not because it is newer: it is drawn from a review period rather than from the
+ // alerts on this screen. A customer appears here only once a rule has fired, so judging them on those alerts
+ // alone describes the exceptions and never the ordinary months around them - which is exactly what a rating is
+ // supposed to reflect. The job also tests the things this screen cannot see at all: how many separate months the
+ // activity falls in, how long the customer has held the rating, whether an STR inside the block window forbids
+ // lowering it.
+ //
+ // Without a window - a tenant where the job has not run, or a customer it has not reached - the per-alert
+ // reckoning below still stands, so the report never renders blank while the two are in step.
+ // A window is only good for as long as the rating it was computed against. The job takes a snapshot each night
+ // and records the rating the customer held at the time; once an officer moves that rating the snapshot describes
+ // a customer who no longer exists, and the screen would go on asking for a change that has already been applied
+ // and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up.
+ const raw = windows[clientId];
+ const staleWindow =
+ raw?.riskCategoryAtReview != null &&
+ currentRisk != null &&
+ RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk;
+ const window = staleWindow ? undefined : raw;
+ const recommendation = window?.suggestedDirection
+ ? {
+ risk: RANK_TO_LEVEL[window.suggestedCategory ?? 0] ?? currentRisk ?? 'LOW',
+ reason: window.suggestionReason ?? ''
+ }
+ : recommendRating(
+ proposedRank,
+ currentRisk,
+ largestAmount,
+ totalAmount,
+ escalated,
+ alerts.map((a) => a.ruleCode),
+ alerts.length,
+ // Distinct calendar months the alerts fall in. Count alone does not establish a period: five alerts in one
+ // week is a busy fortnight, not five months of steady conduct.
+ new Set(alerts.map((a) => a.alertDate.slice(0, 7))).size
+ );
// Where the customer carries no rating there is nothing to move, so an
// unrated customer is never counted as an upgrade or a downgrade.
const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0;
- const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same';
+ const direction: RatingDirection = window?.suggestedDirection
+ ? window.suggestedDirection === 'UP'
+ ? 'up'
+ : window.suggestedDirection === 'DOWN'
+ ? 'down'
+ : 'same'
+ : delta > 0
+ ? 'up'
+ : delta < 0
+ ? 'down'
+ : 'same';
rows.push({
clientId,
@@ -332,7 +450,10 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
escalated,
needsUpgrade: direction === 'up',
- needsDowngrade: direction === 'down'
+ needsDowngrade: direction === 'down',
+ // Carried through so the screen can show the period a recommendation was drawn from. A rating an officer is
+ // asked to approve has to show its working, and the alerts on this row are not it.
+ behaviourWindow: window ?? null
});
}
@@ -461,9 +582,13 @@ export interface RiskReviewModel {
* the summary all run once per filter change rather than once per template
* binding, which matters because every view reads from the same result.
*/
-export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel {
+export function buildRiskReview(
+ alerts: RiskAlertRow[],
+ filter: RiskReviewFilter,
+ windows: BehaviourWindowByClient = {}
+): RiskReviewModel {
const base = riskAlertViews(alerts, filter);
- const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction);
+ const narrowed = applyDirectionFilter(base, riskCustomerRows(base, windows), filter.direction);
return {
alerts: narrowed.views,
diff --git a/src/app/shared/icons.module.ts b/src/app/shared/icons.module.ts
index 07f2b7ee5..cce399f5a 100644
--- a/src/app/shared/icons.module.ts
+++ b/src/app/shared/icons.module.ts
@@ -56,6 +56,7 @@ import {
faGavel,
faGlobe,
faHandHoldingUsd,
+ faHandPaper,
faHome,
faKey,
faLink,
@@ -303,6 +304,7 @@ export class IconsModule {
faHandHoldingHand,
faHandHoldingHeart,
faHandHoldingUsd,
+ faHandPaper,
faHeart,
faHome,
faKey,
diff --git a/src/app/shares/common-resolvers/share-account-actions.resolver.ts b/src/app/shares/common-resolvers/share-account-actions.resolver.ts
index 21749db82..24679e699 100644
--- a/src/app/shares/common-resolvers/share-account-actions.resolver.ts
+++ b/src/app/shares/common-resolvers/share-account-actions.resolver.ts
@@ -32,7 +32,6 @@ export class ShareAccountActionsResolver {
const actionName = route.paramMap.get('name');
const shareAccountId = route.paramMap.get('shareAccountId') || route.parent.parent.paramMap.get('shareAccountId');
switch (actionName) {
- case 'Apply Additional Shares':
case 'Redeem Shares':
case 'Approve Additional Shares':
case 'Reject Additional Shares':
diff --git a/src/app/shares/reports/share-account-statement/share-account-statement.component.html b/src/app/shares/reports/share-account-statement/share-account-statement.component.html
new file mode 100644
index 000000000..8e2f1d259
--- /dev/null
+++ b/src/app/shares/reports/share-account-statement/share-account-statement.component.html
@@ -0,0 +1,209 @@
+
+
+
+
+
+
+
+
Share Account Statement
+
+ One member's share account, movement by movement, with the holding each movement left behind.
+
diff --git a/src/app/shares/reports/share-account-statement/share-account-statement.component.ts b/src/app/shares/reports/share-account-statement/share-account-statement.component.ts
new file mode 100644
index 000000000..2272c7595
--- /dev/null
+++ b/src/app/shares/reports/share-account-statement/share-account-statement.component.ts
@@ -0,0 +1,238 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { Subject, of } from 'rxjs';
+import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
+
+import { SharesService } from '../../shares.service';
+import {
+ SHARE_ACCOUNT_STATUS,
+ SHARE_TXN_TYPE,
+ fundingLabel,
+ shareReportCsv,
+ shareReportPrint
+} from '../share-report.util';
+
+interface StatementLine {
+ transactionId: number;
+ transactionDate: string;
+ typeEnum: number;
+ statusEnum: number;
+ sharesIn: number;
+ sharesOut: number;
+ unitPrice: number;
+ amount: number;
+ chargeAmount: number;
+ amountPaid: number;
+ resolutionNumber: string;
+ meetingDate: string;
+ fundingSource: string;
+ balanceShares: number;
+ balanceValue: number;
+}
+
+/**
+ * Share Account Statement — one member's share account, movement by movement, with the holding each
+ * movement left behind.
+ *
+ *
+ * The account is chosen first because a statement is always about one account; the search box is
+ * server-side (the same summary endpoint the Share Holder report uses), so it finds a member across
+ * every branch rather than only among accounts already downloaded.
+ *
+ *
diff --git a/src/app/shares/reports/share-area-wise/share-area-wise.component.ts b/src/app/shares/reports/share-area-wise/share-area-wise.component.ts
new file mode 100644
index 000000000..d2abd920e
--- /dev/null
+++ b/src/app/shares/reports/share-area-wise/share-area-wise.component.ts
@@ -0,0 +1,188 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+
+import { SharesService } from '../../shares.service';
+import { shareReportCsv, shareReportPrint } from '../share-report.util';
+
+interface AreaRow {
+ area: string;
+ memberCount: number;
+ shares: number;
+ shareValue: number;
+}
+
+interface AreaMemberRow {
+ clientId: number;
+ cifNumber: string;
+ clientName: string;
+ mobileNo: string;
+ officeName: string;
+ area: string;
+ postalCode: string;
+ sharesHeld: number;
+ shareValue: number;
+}
+
+/**
+ * Area Wise — where the society's members and its share capital actually sit.
+ *
+ *
+ * A co-operative's area of operation is part of its licence, so "how much of our capital comes from
+ * which area" is a question both the board and the Registrar ask. Grouped on the member's own
+ * recorded address rather than the branch that serves them: the two answer different questions, and
+ * a licence is written against the address.
+ *
+ *
+ Which numbered shares each member holds — the range every allotment covers, per share class.
+
+
+
+
+
+ Export CSV
+
+
+ Print
+
+
+
+
+ @if (totals) {
+
+
+
+
+
{{ totals.allotmentCount | number }}
+
Numbered Allotments
+
+
+
+
+
+
{{ totals.sharesNumbered | number }}
+
Shares Numbered
+
+
+
+ }
+
+
+
+
+
+ Distinctive numbers are derived from issue order within each share class — no distinctive-number column
+ exists in the system, so each paid-up allotment takes the next block in sequence. Redeemed shares leave a gap
+ rather than renumbering everybody else, exactly as a physical register would. Only paid-up allotments are
+ numbered.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Clear filters
+
+
+
+ @if (loading) {
+
Loading the distinctive number register…
+ } @else if (loadError) {
+
{{ loadError }}
+ } @else if (!rows.length) {
+
No paid-up allotment has been made yet, so no share carries a number.
+ } @else if (!filteredRows.length) {
+
+ @if (shareNumberLookup) {
+ Share number {{ shareNumberLookup | number }} has not been allotted in this share class.
+ } @else {
+ No allotment matches the filters applied.
+ }
+
Search for a member above to see their dividend statement.
+ }
+
diff --git a/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.ts b/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.ts
new file mode 100644
index 000000000..811f790be
--- /dev/null
+++ b/src/app/shares/reports/share-dividend-statement/share-dividend-statement.component.ts
@@ -0,0 +1,151 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+
+import { SharesService } from '../../shares.service';
+import { SHARE_DIVIDEND_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util';
+
+/**
+ * Dividend Statement — every dividend declared to one member, with what has been paid and what is
+ * still owed.
+ *
+ *
Search for a member above to see their full share position.
+ }
+
diff --git a/src/app/shares/reports/share-enquiry/share-enquiry.component.ts b/src/app/shares/reports/share-enquiry/share-enquiry.component.ts
new file mode 100644
index 000000000..34b306790
--- /dev/null
+++ b/src/app/shares/reports/share-enquiry/share-enquiry.component.ts
@@ -0,0 +1,246 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+
+import { SharesService } from '../../shares.service';
+import {
+ SHARE_TXN_STATUS,
+ SHARE_TXN_TYPE,
+ fundingLabel,
+ holdingStateClass,
+ holdingStateLabel,
+ holdingStateNote,
+ holdingStateRowClass,
+ shareReportCsv,
+ shareReportPrint
+} from '../share-report.util';
+
+/** m_share_account_dividend_details.status — where a member's dividend has got to. */
+const DIVIDEND_STATUS: { [key: number]: { label: string; cls: string } } = {
+ 100: { label: 'Declared', cls: 'srep-pill--amber' },
+ 300: { label: 'Approved', cls: 'srep-pill--blue' },
+ 600: { label: 'Paid', cls: 'srep-pill--green' }
+};
+
+/**
+ * Share Enquiry — everything about one member's share position in a single answer: their accounts,
+ * every movement, the dividends declared to them, and the savings account their money moves through.
+ *
+ *
+ * Built for the counter. Someone is standing in front of the clerk asking "how many shares do I
+ * hold, when did I buy them, and what dividend am I owed" — three screens is three chances to read
+ * the wrong member's row out loud, so this is one search and one page.
+ *
+ *
+ Share capital held by each member as on a date — purchases less redemptions, settled only.
+
+
+
+
+
+ Export CSV
+
+
+ Print
+
+
+
+
+
+ @if (totals) {
+
+
+
+
+
{{ totals.memberCount | number }}
+
Members Holding
+
+
+
+
+
+
{{ totals.sharesHeld | number }}
+
Shares Held
+
+
+
+
+
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Share Capital
+
+
+
+
+
+
₹{{ totals.amountRedeemed | number: '1.2-2' }}
+
Redeemed To Date
+
+
+
+ }
+
+
+ @if (hasUnpaidAllotments) {
+
+
+
+ {{ unpaidAllotments.accountCount | number }}
+ {{ unpaidAllotments.accountCount === 1 ? 'account has' : 'accounts have' }} shares allotted but not yet paid for
+ — {{ unpaidAllotments.shares | number }} shares, ₹{{ unpaidAllotments.amount | number: '1.2-2' }}. These are excluded above: share capital is what the society has received, not what it has agreed to issue.
+ Apply the member's collection to bring them in.
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Clear filters
+
+
+
+ @if (loading) {
+
Loading share balances…
+ } @else if (loadError) {
+
{{ loadError }}
+ } @else if (!rows.length) {
+
No member held share capital as on {{ asOnDate }}.
+ {{ isFiltered ? 'Total (filtered)' : 'Total' }} — {{ filteredRows.length | number }} members
+
+
{{ shownShares | number }}
+
+
₹{{ shownValue | number: '1.2-2' }}
+
+
+
+
+
+
+
+
+ @if (isFiltered && totals) {
+
+ Showing a filtered view. Society-wide as on {{ asOnDate }}: {{ totals.sharesHeld | number }} shares held by
+ {{ totals.memberCount | number }} members, ₹{{ totals.shareValue | number: '1.2-2' }} share capital.
+
+ }
+ }
+
diff --git a/src/app/shares/reports/share-final-balance/share-final-balance.component.ts b/src/app/shares/reports/share-final-balance/share-final-balance.component.ts
new file mode 100644
index 000000000..63fb2500a
--- /dev/null
+++ b/src/app/shares/reports/share-final-balance/share-final-balance.component.ts
@@ -0,0 +1,279 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+
+import { SharesService } from '../../shares.service';
+import { SettingsService } from 'app/settings/settings.service';
+import {
+ SHARE_ACCOUNT_STATUS,
+ holdingStateClass,
+ holdingStateLabel,
+ holdingStateNote,
+ holdingStateRowClass,
+ shareReportCsv,
+ shareReportPrint
+} from '../share-report.util';
+
+/** One member's closing shareholding. Shapes the row the server sends; nothing is derived here. */
+interface FinalBalanceRow {
+ shareAccountId: number;
+ accountNo: string;
+ clientId: number;
+ cifNumber: string;
+ clientName: string;
+ officeId: number;
+ officeName: string;
+ productId: number;
+ productName: string;
+ currencyCode: string;
+ statusEnum: number;
+ holdingState: string;
+ unpaidShares: number;
+ unitPrice: number;
+ sharesHeld: number;
+ shareValue: number;
+ sharesPurchased: number;
+ sharesRedeemed: number;
+ amountSubscribed: number;
+ amountRedeemed: number;
+ firstMovementDate: string;
+ lastMovementDate: string;
+ activatedDate: string;
+ closedDate: string;
+}
+
+/**
+ * Final Balance — what each member's share capital stands at on a given date, and what the society's
+ * Share Capital account should therefore read.
+ *
+ *
+ * The date is the whole point of the report, so it is asked of the server rather than filtered in the
+ * browser: a holding "as on 31 March" means walking that member's purchases and redemptions up to
+ * that date, which the rows already on screen cannot answer. Changing the date re-queries.
+ *
+ *
+ * Branch, product and search are client-side, because they only ever narrow rows already fetched and
+ * a round trip for each keystroke would be worse than useless.
+ */
+@Component({
+ selector: 'mifosx-share-final-balance',
+ templateUrl: './share-final-balance.component.html',
+ styleUrls: [
+ '../../../letter-mgmt/certificates/certificates.shared.scss',
+ '../share-report.shared.scss'
+ ],
+ imports: [
+ CommonModule,
+ FormsModule,
+ FaIconComponent
+ ]
+})
+export class ShareFinalBalanceComponent implements OnInit {
+ private sharesService = inject(SharesService);
+ private settingsService = inject(SettingsService);
+ private router = inject(Router);
+
+ loading = true;
+ loadError = '';
+ rows: FinalBalanceRow[] = [];
+ totals: any = null;
+
+ /**
+ * Shares allotted to a member whose money never arrived — the account was activated but its
+ * collection was never applied.
+ *
+ * Deliberately kept out of the balance (share capital is what the society received, not what it
+ * promised to issue) and deliberately shown anyway: a total that silently drops accounts leaves a
+ * reader wondering which, and an allotment sitting unpaid is the thing worth chasing.
+ */
+ unpaidAllotments: { accountCount: number; shares: number; amount: number } | null = null;
+
+ /** Server-side filter — changing it re-queries, since the balance itself depends on it. */
+ asOnDate = '';
+ /** Closed and fully-redeemed members, off by default: a closing-balance report lists holders. */
+ includeZero = false;
+
+ /** Client-side filters. */
+ search = '';
+ branchFilter = '';
+ productFilter = '';
+
+ ngOnInit(): void {
+ const businessDate: Date = this.settingsService.businessDate || new Date();
+ this.asOnDate = this.toIso(businessDate);
+ this.load();
+ }
+
+ load(): void {
+ this.loading = true;
+ this.loadError = '';
+ this.sharesService
+ .getShareFinalBalance({ asOnDate: this.asOnDate, includeZero: this.includeZero })
+ .pipe(
+ catchError((err) => {
+ this.loadError = this.readError(err, 'The share balance report could not be loaded.');
+ return of(null);
+ })
+ )
+ .subscribe((res: any) => {
+ this.rows = res?.rows || [];
+ this.totals = res?.totals || null;
+ this.unpaidAllotments = res?.unpaidAllotments || null;
+ this.loading = false;
+ });
+ }
+
+ get hasUnpaidAllotments(): boolean {
+ return !!(this.unpaidAllotments && Number(this.unpaidAllotments.accountCount) > 0);
+ }
+
+ stateLabel(state: string): string {
+ return holdingStateLabel(state);
+ }
+
+ stateClass(state: string): string {
+ return holdingStateClass(state);
+ }
+
+ stateRowClass(state: string): string {
+ return holdingStateRowClass(state);
+ }
+
+ stateNote(state: string): string {
+ return holdingStateNote(state);
+ }
+
+ get branchOptions(): string[] {
+ return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
+ }
+
+ get productOptions(): string[] {
+ return [...new Set(this.rows.map((r) => r.productName).filter(Boolean))].sort();
+ }
+
+ get filteredRows(): FinalBalanceRow[] {
+ const term = this.search.trim().toLowerCase();
+ return this.rows.filter((r) => {
+ if (this.branchFilter && r.officeName !== this.branchFilter) {
+ return false;
+ }
+ if (this.productFilter && r.productName !== this.productFilter) {
+ return false;
+ }
+ if (!term) {
+ return true;
+ }
+ return [
+ r.clientName,
+ r.accountNo,
+ r.cifNumber,
+ r.productName
+ ]
+ .filter(Boolean)
+ .some((field) => String(field).toLowerCase().includes(term));
+ });
+ }
+
+ /**
+ * Totals for what is on screen. The server's own totals cover the unfiltered report and are shown
+ * beside these, so a narrowed view can never be mistaken for the society's whole share capital.
+ */
+ get shownShares(): number {
+ return this.filteredRows.reduce((sum, r) => sum + Number(r.sharesHeld || 0), 0);
+ }
+
+ get shownValue(): number {
+ return this.filteredRows.reduce((sum, r) => sum + Number(r.shareValue || 0), 0);
+ }
+
+ get isFiltered(): boolean {
+ return !!(this.search.trim() || this.branchFilter || this.productFilter);
+ }
+
+ statusLabel(statusEnum: number): string {
+ return SHARE_ACCOUNT_STATUS[statusEnum]?.label || String(statusEnum ?? '');
+ }
+
+ clearFilters(): void {
+ this.search = '';
+ this.branchFilter = '';
+ this.productFilter = '';
+ }
+
+ openAccount(row: FinalBalanceRow): void {
+ this.router.navigate([
+ '/clients',
+ row.clientId,
+ 'shares-accounts',
+ row.shareAccountId,
+ 'general'
+ ]);
+ }
+
+ exportCsv(): void {
+ shareReportCsv(
+ `share-final-balance-${this.asOnDate || 'today'}`,
+ [
+ 'Member',
+ 'Membership No',
+ 'Share A/c No',
+ 'Branch',
+ 'Product',
+ 'Status',
+ 'Holding State',
+ 'Allotted Unpaid',
+ 'Shares Held',
+ 'Unit Price',
+ 'Share Value',
+ 'Shares Purchased',
+ 'Shares Redeemed',
+ 'Last Movement'
+ ],
+ this.filteredRows.map((r) => [
+ r.clientName,
+ r.cifNumber,
+ r.accountNo,
+ r.officeName,
+ r.productName,
+ this.statusLabel(r.statusEnum),
+ this.stateLabel(r.holdingState),
+ r.unpaidShares,
+ r.sharesHeld,
+ r.unitPrice,
+ r.shareValue,
+ r.sharesPurchased,
+ r.sharesRedeemed,
+ r.lastMovementDate
+ ])
+ );
+ }
+
+ print(): void {
+ shareReportPrint();
+ }
+
+ private toIso(date: Date): string {
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
+ }
+
+ 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/reports/share-folio-statement/share-folio-statement.component.html b/src/app/shares/reports/share-folio-statement/share-folio-statement.component.html
new file mode 100644
index 000000000..55f5134b1
--- /dev/null
+++ b/src/app/shares/reports/share-folio-statement/share-folio-statement.component.html
@@ -0,0 +1,250 @@
+
+
+
+
+
+
+
+
Folio Statement
+
+ A member's folio in the share register — every account they hold, with the movements running across all of
+ them.
+
diff --git a/src/app/shares/reports/share-folio-statement/share-folio-statement.component.ts b/src/app/shares/reports/share-folio-statement/share-folio-statement.component.ts
new file mode 100644
index 000000000..07539a841
--- /dev/null
+++ b/src/app/shares/reports/share-folio-statement/share-folio-statement.component.ts
@@ -0,0 +1,217 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+
+import { SharesService } from '../../shares.service';
+import {
+ SHARE_TXN_TYPE,
+ fundingLabel,
+ holdingStateClass,
+ holdingStateLabel,
+ holdingStateNote,
+ holdingStateRowClass,
+ shareReportCsv,
+ shareReportPrint
+} from '../share-report.util';
+
+/**
+ * Folio Statement — one member's folio in the share register: every account they hold and every
+ * movement across all of them, in one running sequence.
+ *
+ *
+ Showing a filtered view. Whole period {{ fromDate }} to {{ toDate }}: {{ totals.shares | number }} shares issued
+ to {{ totals.memberCount | number }} members across {{ totals.trancheCount | number }} allotments, ₹{{
+ totals.amount | number: '1.2-2'
+ }}.
+
+ }
+ }
+
diff --git a/src/app/shares/reports/share-issued-report/share-issued-report.component.ts b/src/app/shares/reports/share-issued-report/share-issued-report.component.ts
new file mode 100644
index 000000000..abefc22c0
--- /dev/null
+++ b/src/app/shares/reports/share-issued-report/share-issued-report.component.ts
@@ -0,0 +1,264 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+
+import { SharesService } from '../../shares.service';
+import { SettingsService } from 'app/settings/settings.service';
+import { SHARE_TXN_STATUS, fundingLabel, shareReportCsv, shareReportPrint } from '../share-report.util';
+
+interface IssuedRow {
+ transactionId: number;
+ issueDate: string;
+ shareAccountId: number;
+ accountNo: string;
+ clientId: number;
+ cifNumber: string;
+ clientName: string;
+ officeId: number;
+ officeName: string;
+ productId: number;
+ productName: string;
+ currencyCode: string;
+ shares: number;
+ unitPrice: number;
+ amount: number;
+ chargeAmount: number;
+ statusEnum: number;
+ resolutionNumber: string;
+ meetingDate: string;
+ fundingSource: string;
+ accountStatusEnum: number;
+}
+
+/**
+ * Share Issued — the allotment register: every tranche of shares issued in a period, with the board
+ * resolution that authorised it and how the member paid.
+ *
+ *
+ * One row per tranche, not per member: a member who bought three times was allotted three times, and
+ * the register records allotments. Rolling them up per member would lose the resolution each one was
+ * made under, which is the column an auditor actually reads.
+ *
+ *
+
+
+ {{ unaddressableRows.length | number }} shareholding member{{
+ unaddressableRows.length === 1 ? ' has' : 's have'
+ }}
+ no address on file, so no label is printed for them: {{ unaddressableRows[0].clientName }}
+ @if (unaddressableRows.length > 1) {
+ and {{ unaddressableRows.length - 1 | number }} other{{ unaddressableRows.length > 2 ? 's' : '' }}
+ }
+ . Record an address against the member to include them in the next run.
+
+
+ }
+ }
+
diff --git a/src/app/shares/reports/share-label/share-label.component.ts b/src/app/shares/reports/share-label/share-label.component.ts
new file mode 100644
index 000000000..b9268f267
--- /dev/null
+++ b/src/app/shares/reports/share-label/share-label.component.ts
@@ -0,0 +1,197 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+
+import { SharesService } from '../../shares.service';
+import { shareReportCsv, shareReportPrint } from '../share-report.util';
+
+export interface MailingRow {
+ clientId: number;
+ folioNumber: string;
+ clientName: string;
+ mobileNo: string;
+ officeName: string;
+ addressLine1: string;
+ addressLine2: string;
+ street: string;
+ city: string;
+ district: string;
+ postalCode: string;
+ area: string;
+ sharesHeld: number;
+ shareValue: number;
+ shareAccountNos: string;
+ addressable: boolean;
+}
+
+/** Assembles one member's address into the lines an envelope actually carries. */
+export function addressLines(row: MailingRow): string[] {
+ return [
+ row.addressLine1,
+ row.addressLine2,
+ row.street,
+ [
+ row.city,
+ row.district
+ ]
+ .filter(Boolean)
+ .join(', '),
+ row.postalCode ? `PIN ${row.postalCode}` : ''
+ ]
+ .map((line) => (line || '').trim())
+ .filter(Boolean);
+}
+
+/**
+ * Share Label — printable address labels for shareholding members.
+ *
+ *
+ * Laid out as a grid sized to ordinary label sheets and printed straight from the browser, so a
+ * mailing run needs no separate mail-merge tool. Members with no usable address are listed
+ * separately rather than printed as a blank label: a blank label is a wasted sheet and a member who
+ * silently never hears from the society.
+ */
+@Component({
+ selector: 'mifosx-share-label',
+ templateUrl: './share-label.component.html',
+ styleUrls: [
+ '../../../letter-mgmt/certificates/certificates.shared.scss',
+ '../share-report.shared.scss'
+ ],
+ imports: [
+ CommonModule,
+ FormsModule,
+ FaIconComponent
+ ]
+})
+export class ShareLabelComponent implements OnInit {
+ private sharesService = inject(SharesService);
+
+ loading = true;
+ loadError = '';
+ rows: MailingRow[] = [];
+ totals: any = null;
+
+ search = '';
+ areaFilter = '';
+ /** Labels per row on the sheet — 2 and 3 cover the sheets a branch actually buys. */
+ columns = 3;
+ showFolio = true;
+ showHolding = false;
+
+ ngOnInit(): void {
+ this.load();
+ }
+
+ load(): void {
+ this.loading = true;
+ this.loadError = '';
+ this.sharesService
+ .getShareMailingList({})
+ .pipe(
+ catchError((err) => {
+ this.loadError = this.readError(err, 'The mailing list could not be loaded.');
+ return of(null);
+ })
+ )
+ .subscribe((res: any) => {
+ this.rows = res?.rows || [];
+ this.totals = res?.totals || null;
+ this.loading = false;
+ });
+ }
+
+ get areaOptions(): string[] {
+ return [...new Set(this.rows.map((r) => r.area).filter(Boolean))].sort();
+ }
+
+ /** Only members who can actually be posted to — the rest are listed below, not printed. */
+ get printableRows(): MailingRow[] {
+ return this.filteredRows.filter((r) => r.addressable);
+ }
+
+ get unaddressableRows(): MailingRow[] {
+ return this.filteredRows.filter((r) => !r.addressable);
+ }
+
+ get filteredRows(): MailingRow[] {
+ const term = this.search.trim().toLowerCase();
+ return this.rows.filter((r) => {
+ if (this.areaFilter && r.area !== this.areaFilter) {
+ return false;
+ }
+ if (!term) {
+ return true;
+ }
+ return [
+ r.clientName,
+ r.folioNumber,
+ r.area,
+ r.postalCode
+ ]
+ .filter(Boolean)
+ .some((field) => String(field).toLowerCase().includes(term));
+ });
+ }
+
+ lines(row: MailingRow): string[] {
+ return addressLines(row);
+ }
+
+ clearFilters(): void {
+ this.search = '';
+ this.areaFilter = '';
+ }
+
+ exportCsv(): void {
+ shareReportCsv(
+ 'share-mailing-labels',
+ [
+ 'Member',
+ 'Folio No',
+ 'Address',
+ 'City',
+ 'District',
+ 'Postal Code',
+ 'Mobile',
+ 'Shares Held',
+ 'Addressable'
+ ],
+ this.filteredRows.map((r) => [
+ r.clientName,
+ r.folioNumber,
+ this.lines(r).join(' / '),
+ r.city,
+ r.district,
+ r.postalCode,
+ r.mobileNo,
+ r.sharesHeld,
+ r.addressable ? 'Yes' : 'No'
+ ])
+ );
+ }
+
+ print(): void {
+ shareReportPrint();
+ }
+
+ 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/reports/share-letter/share-letter.component.html b/src/app/shares/reports/share-letter/share-letter.component.html
new file mode 100644
index 000000000..a30b55f7a
--- /dev/null
+++ b/src/app/shares/reports/share-letter/share-letter.component.html
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
+
Share Letter
+
+ A letter to shareholding members, merged with their own details and printed — one page each.
+
diff --git a/src/app/shares/reports/share-letter/share-letter.component.ts b/src/app/shares/reports/share-letter/share-letter.component.ts
new file mode 100644
index 000000000..056cdb00a
--- /dev/null
+++ b/src/app/shares/reports/share-letter/share-letter.component.ts
@@ -0,0 +1,227 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+
+import { SharesService } from '../../shares.service';
+import { OrganizationService } from 'app/organization/organization.service';
+import { MailingRow, addressLines } from '../share-label/share-label.component';
+import { shareReportPrint } from '../share-report.util';
+
+/** The letters a society actually sends its shareholders, as starting text. */
+const TEMPLATES: { key: string; label: string; subject: string; body: string }[] = [
+ {
+ key: 'AGM',
+ label: 'Notice of Annual General Meeting',
+ subject: 'Notice of the Annual General Meeting',
+ body:
+ 'You are hereby informed that the Annual General Meeting of the society will be held on {{MEETING_DATE}} at {{VENUE}}.\n\n' +
+ 'As a shareholding member holding {{SHARES}} shares (Folio {{FOLIO}}), you are entitled to attend and vote.\n\n' +
+ 'Your presence is requested.'
+ },
+ {
+ key: 'DIVIDEND',
+ label: 'Dividend intimation',
+ subject: 'Dividend declared on your shareholding',
+ body:
+ 'We are pleased to inform you that the society has declared a dividend for the year {{YEAR}}.\n\n' +
+ 'Against your holding of {{SHARES}} shares (Folio {{FOLIO}}), the dividend due to you will be credited to your ' +
+ 'linked savings account. Please contact your branch if it is not received within fifteen days.'
+ },
+ {
+ key: 'SHARE_CALL',
+ label: 'Call to complete shareholding',
+ subject: 'Completion of your share subscription',
+ body:
+ 'Our records show that your share account stands at {{SHARES}} shares (Folio {{FOLIO}}), which is below the ' +
+ 'minimum subscription your share class requires.\n\n' +
+ 'You are requested to complete your subscription at your branch at the earliest so that your membership rights ' +
+ 'remain unaffected.'
+ },
+ {
+ key: 'BLANK',
+ label: 'Blank letter',
+ subject: '',
+ body: ''
+ }
+];
+
+/**
+ * Share Letter — a letter to shareholding members, merged with their own details and printed.
+ *
+ *
+ * Runs off the same mailing list the labels do, so the letter and the envelope always agree on who
+ * is being written to. Templates are starting text rather than fixed forms: the body is editable
+ * before printing, because the letter a society actually sends is never quite the one a system
+ * imagined.
+ *
+ *
+ * Placeholders are merged per member — {{NAME}}, {{FOLIO}}, {{SHARES}} and the rest — so one edit
+ * produces every letter, each addressed to the member it is going to.
+ */
+@Component({
+ selector: 'mifosx-share-letter',
+ templateUrl: './share-letter.component.html',
+ styleUrls: [
+ '../../../letter-mgmt/certificates/certificates.shared.scss',
+ '../share-report.shared.scss'
+ ],
+ imports: [
+ CommonModule,
+ FormsModule,
+ FaIconComponent
+ ]
+})
+export class ShareLetterComponent implements OnInit {
+ private sharesService = inject(SharesService);
+ private organizationService = inject(OrganizationService);
+
+ loading = true;
+ loadError = '';
+ rows: MailingRow[] = [];
+ institution: any = null;
+
+ readonly templates = TEMPLATES;
+ templateKey = 'AGM';
+ subject = TEMPLATES[0].subject;
+ body = TEMPLATES[0].body;
+
+ search = '';
+ areaFilter = '';
+ /** Which members this run is for; empty means everyone shown. */
+ selectedIds: number[] = [];
+
+ ngOnInit(): void {
+ this.sharesService
+ .getShareMailingList({})
+ .pipe(
+ catchError((err) => {
+ this.loadError = this.readError(err, 'The mailing list could not be loaded.');
+ return of(null);
+ })
+ )
+ .subscribe((res: any) => {
+ this.rows = res?.rows || [];
+ this.loading = false;
+ });
+
+ this.organizationService
+ .getInstitutionProfile()
+ .pipe(catchError(() => of(null)))
+ .subscribe((res: any) => (this.institution = res));
+ }
+
+ applyTemplate(): void {
+ const template = TEMPLATES.find((t) => t.key === this.templateKey);
+ if (template) {
+ this.subject = template.subject;
+ this.body = template.body;
+ }
+ }
+
+ get areaOptions(): string[] {
+ return [...new Set(this.rows.map((r) => r.area).filter(Boolean))].sort();
+ }
+
+ get filteredRows(): MailingRow[] {
+ const term = this.search.trim().toLowerCase();
+ return this.rows.filter((r) => {
+ if (this.areaFilter && r.area !== this.areaFilter) {
+ return false;
+ }
+ if (!term) {
+ return true;
+ }
+ return [
+ r.clientName,
+ r.folioNumber,
+ r.area
+ ]
+ .filter(Boolean)
+ .some((field) => String(field).toLowerCase().includes(term));
+ });
+ }
+
+ /** Nothing ticked means the whole filtered list — the common case is "write to all of these". */
+ get recipients(): MailingRow[] {
+ if (!this.selectedIds.length) {
+ return this.filteredRows;
+ }
+ return this.filteredRows.filter((r) => this.selectedIds.includes(r.clientId));
+ }
+
+ isSelected(clientId: number): boolean {
+ return this.selectedIds.includes(clientId);
+ }
+
+ toggle(clientId: number): void {
+ this.selectedIds = this.isSelected(clientId) ? this.selectedIds.filter((id) => id !== clientId) : [
+ ...this.selectedIds,
+ clientId
+ ];
+ }
+
+ selectAll(): void {
+ this.selectedIds = [];
+ }
+
+ lines(row: MailingRow): string[] {
+ return addressLines(row);
+ }
+
+ /** What one member's letter substitutes, keyed by the token that stands for it in the body. */
+ private mergeFields(row: MailingRow): Record {
+ return {
+ NAME: row.clientName || '',
+ FOLIO: row.folioNumber || '',
+ SHARES: Number(row.sharesHeld || 0).toLocaleString('en-IN'),
+ SHARE_VALUE: Number(row.shareValue || 0).toLocaleString('en-IN'),
+ SHARE_ACCOUNTS: row.shareAccountNos || '',
+ BRANCH: row.officeName || '',
+ AREA: row.area || ''
+ };
+ }
+
+ /**
+ * The tokens the editor's help text offers, derived from the merge itself so the two can never
+ * drift apart — and assembled here rather than written in the template, because a literal
+ * `{{NAME}}` in an Angular template is an expression, not the text a letter-writer needs to see.
+ */
+ readonly placeholders = Object.keys(this.mergeFields({} as MailingRow)).map((key) => `{{${key}}}`);
+
+ /** The member's own details merged into the body, one letter at a time. */
+ merged(row: MailingRow): string {
+ const fields = this.mergeFields(row);
+ // An unrecognised token is left standing rather than blanked: a letter that visibly still says
+ // {{VENUE}} tells the writer to fill it in; one silently emptied goes out with a hole in it.
+ return (this.body || '').replace(/\{\{([A-Z_]+)\}\}/g, (token, key: string) => fields[key] ?? token);
+ }
+
+ get today(): string {
+ const now = new Date();
+ return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`;
+ }
+
+ print(): void {
+ shareReportPrint();
+ }
+
+ 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/reports/share-member-interest-report/share-member-interest-report.component.html b/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.html
new file mode 100644
index 000000000..166169c80
--- /dev/null
+++ b/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.html
@@ -0,0 +1,166 @@
+
+
+
+
+
+
+
+
Total Interest Received & Receivable
+
+ Interest earned from the borrowings of the members who hold the society's share capital.
+
diff --git a/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.ts b/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.ts
new file mode 100644
index 000000000..e87fe1544
--- /dev/null
+++ b/src/app/shares/reports/share-member-interest-report/share-member-interest-report.component.ts
@@ -0,0 +1,197 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+
+import { SharesService } from '../../shares.service';
+import { shareReportCsv, shareReportPrint } from '../share-report.util';
+
+interface MemberInterestRow {
+ clientId: number;
+ cifNumber: string;
+ clientName: string;
+ officeName: string;
+ sharesHeld: number;
+ shareValue: number;
+ loanCount: number;
+ principalLent: number;
+ principalOutstanding: number;
+ interestCharged: number;
+ interestReceived: number;
+ interestReceivable: number;
+ interestWaived: number;
+ interestWrittenOff: number;
+}
+
+/**
+ * Total Interest Received & Receivable — what the society has earned in interest from the borrowings
+ * of the members who hold its share capital.
+ *
+ *
+ * Shares earn dividend, not interest; this is the other side of the member relationship. A member's
+ * stake and what their lending returns is the case for admitting and keeping them, and a
+ * co-operative reads the two together.
+ *
+ *
+ Each member's holding at face value, measured against the share class's nominal, minimum and maximum.
+
+
+
+
+
+ Export CSV
+
+
+ Print
+
+
+
+
+ @if (totals) {
+
+
+
+
+
₹{{ totals.nominalValue | number: '1.2-2' }}
+
Nominal Capital
+
+
+
+
+
+
{{ totals.regularMembers | number }}
+
Regular Members
+
+
+
+
+
+
{{ totals.nominalMembers | number }}
+
Nominal Members
+
+
+
+
+
+
{{ totals.belowMinimum + totals.aboveMaximum | number }}
+
Outside Limits
+
+
+
+ }
+
+
+ @if (unpaidRows.length) {
+
+
+
+ {{ unpaidRows.length | number }} {{ unpaidRows.length === 1 ? 'account has' : 'accounts have' }} shares
+ allotted but never collected — {{ unpaidShares | number }} shares in all. They are active, so they are
+ listed below and flagged in amber, but they hold nothing: the shares exist on paper only until the member's
+ collection is applied.
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Clear filters
+
+
+
+ @if (loading) {
+
Loading nominal share positions…
+ } @else if (loadError) {
+
{{ loadError }}
+ } @else if (!rows.length) {
+
No share account existed as on {{ asOnDate }}.
+ } @else if (!filteredRows.length) {
+
No member matches the filters applied.
+ } @else {
+ @if (hiddenByStateCount) {
+
+ {{ hiddenByStateCount | number }} closed, rejected or not-yet-active
+ {{ hiddenByStateCount === 1 ? 'account is' : 'accounts are' }} not shown. Switch Account state to see
+ them.
+
+ This is to certify that {{ account.clientName }}
+ @if (account.cifNumber) {
+ (Membership No. {{ account.cifNumber }})
+ }
+ @if (account.officeName) {
+ of our {{ account.officeName }} branch
+ }
+ has paid the amounts set out below towards
+ {{ totals?.sharesPaidFor | number }} shares of {{ account.productName }} at ₹{{
+ account.unitPrice | number: '1.2-2'
+ }}
+ each, held under share account {{ account.accountNo }}.
+
+ Account status: {{ statusLabel(account.statusEnum) }}. Shares currently held:
+ {{ account.sharesHeld | number }}.
+
+
+
+
+
Authorised Signatory
+
+
+
+ }
+
diff --git a/src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.ts b/src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.ts
new file mode 100644
index 000000000..0eba4b455
--- /dev/null
+++ b/src/app/shares/reports/share-payment-certificate/share-payment-certificate.component.ts
@@ -0,0 +1,236 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { Subject, of } from 'rxjs';
+import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
+
+import { SharesService } from '../../shares.service';
+import { OrganizationService } from 'app/organization/organization.service';
+import { SHARE_ACCOUNT_STATUS, fundingLabel, shareReportPrint } from '../share-report.util';
+
+/**
+ * Share Payment Certificate — proof of what a member paid for their shares, and against which
+ * receipt.
+ *
+ *
+ * Distinct from a share certificate, which certifies ownership: this certifies payment, so it leads
+ * with amounts, dates and the voucher each one was received on. It is the document a member is asked
+ * for when the money, not the holding, has to be proved.
+ *
+ *
+ Every share class against its own authorised capital — shares issued, shares remaining, and the
+ distinctive-number range issued so far. "Issued" means paid up, not merely allotted.
+
+
+
+ {{ totals.unpaidShares | number }} shares across every class are allotted but not yet paid for —
+ ₹{{ totals.unpaidAmount | number: '1.2-2' }} in all. They are excluded from "Issued" above; see Final
+ Balance for which members they belong to.
+
+
+ }
+ }
+
diff --git a/src/app/shares/reports/share-product-summary/share-product-summary.component.ts b/src/app/shares/reports/share-product-summary/share-product-summary.component.ts
new file mode 100644
index 000000000..c2ac6ba91
--- /dev/null
+++ b/src/app/shares/reports/share-product-summary/share-product-summary.component.ts
@@ -0,0 +1,168 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+
+import { SharesService } from '../../shares.service';
+import { shareReportCsv, shareReportPrint } from '../share-report.util';
+
+interface ProductSummaryRow {
+ productId: number;
+ productName: string;
+ productShortName: string;
+ unitPrice: number;
+ minimumClientShares: number;
+ nominalClientShares: number;
+ maximumClientShares: number;
+ authorisedShares: number;
+ authorisedCapital: number;
+ issuedShares: number;
+ issuedCapital: number;
+ availableShares: number;
+ unpaidAccounts: number;
+ unpaidShares: number;
+ unpaidAmount: number;
+ distinctiveFrom: number | null;
+ distinctiveTo: number | null;
+ accountCount: number;
+ memberCount: number;
+}
+
+/**
+ * Share Product Summary — every share class measured against its own authorised capital: how many
+ * shares are issued, how many remain, and the distinctive-number range issued so far.
+ *
+ *
+ * "Issued" is paid-up shares — settled purchases less redemptions, computed the same way every other
+ * report in this suite computes them — not {@code m_share_product.totalsubscribed_shares}. That
+ * platform column counts an allotment the moment it is submitted, before any money has necessarily
+ * arrived, so it runs ahead of what the society has actually received. A board reading this page is
+ * reading the same figure the GL Share Capital account would show, not a subscription count.
+ */
+@Component({
+ selector: 'mifosx-share-product-summary',
+ templateUrl: './share-product-summary.component.html',
+ styleUrls: [
+ '../../../letter-mgmt/certificates/certificates.shared.scss',
+ '../share-report.shared.scss'
+ ],
+ imports: [
+ CommonModule,
+ FaIconComponent
+ ]
+})
+export class ShareProductSummaryComponent implements OnInit {
+ private sharesService = inject(SharesService);
+
+ loading = true;
+ loadError = '';
+ rows: ProductSummaryRow[] = [];
+
+ ngOnInit(): void {
+ this.load();
+ }
+
+ load(): void {
+ this.loading = true;
+ this.loadError = '';
+ this.sharesService
+ .getShareProductSummary()
+ .pipe(
+ catchError((err: any) => {
+ this.loadError = this.readError(err, 'The product summary could not be loaded.');
+ return of(null);
+ })
+ )
+ .subscribe((res: any) => {
+ this.rows = res?.rows || [];
+ this.loading = false;
+ });
+ }
+
+ /** How much of a product's authorised capital is actually issued, for the progress bar. */
+ utilisationPercent(row: ProductSummaryRow): number {
+ if (!row.authorisedShares) {
+ return 0;
+ }
+ const pct = (row.issuedShares / row.authorisedShares) * 100;
+ return Math.max(0, Math.min(100, pct));
+ }
+
+ distinctiveRange(row: ProductSummaryRow): string {
+ if (!row.distinctiveFrom || !row.distinctiveTo) {
+ return 'None allotted yet';
+ }
+ return `${row.distinctiveFrom} – ${row.distinctiveTo}`;
+ }
+
+ get totals() {
+ return {
+ authorisedShares: this.rows.reduce((sum, r) => sum + Number(r.authorisedShares || 0), 0),
+ authorisedCapital: this.rows.reduce((sum, r) => sum + Number(r.authorisedCapital || 0), 0),
+ issuedShares: this.rows.reduce((sum, r) => sum + Number(r.issuedShares || 0), 0),
+ issuedCapital: this.rows.reduce((sum, r) => sum + Number(r.issuedCapital || 0), 0),
+ unpaidShares: this.rows.reduce((sum, r) => sum + Number(r.unpaidShares || 0), 0),
+ unpaidAmount: this.rows.reduce((sum, r) => sum + Number(r.unpaidAmount || 0), 0)
+ };
+ }
+
+ exportCsv(): void {
+ shareReportCsv(
+ 'share-product-summary',
+ [
+ 'Product',
+ 'Unit Price',
+ 'Authorised Shares',
+ 'Authorised Capital',
+ 'Issued Shares',
+ 'Issued Capital',
+ 'Available Shares',
+ 'Unpaid Accounts',
+ 'Unpaid Shares',
+ 'Unpaid Amount',
+ 'Distinctive Range',
+ 'Accounts',
+ 'Members',
+ 'Min / Nominal / Max Per Member'
+ ],
+ this.rows.map((r) => [
+ r.productName,
+ r.unitPrice,
+ r.authorisedShares,
+ r.authorisedCapital,
+ r.issuedShares,
+ r.issuedCapital,
+ r.availableShares,
+ r.unpaidAccounts,
+ r.unpaidShares,
+ r.unpaidAmount,
+ this.distinctiveRange(r),
+ r.accountCount,
+ r.memberCount,
+ `${r.minimumClientShares ?? '—'} / ${r.nominalClientShares ?? '—'} / ${r.maximumClientShares ?? '—'}`
+ ])
+ );
+ }
+
+ print(): void {
+ shareReportPrint();
+ }
+
+ 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/reports/share-report.shared.scss b/src/app/shares/reports/share-report.shared.scss
new file mode 100644
index 000000000..b837ce60f
--- /dev/null
+++ b/src/app/shares/reports/share-report.shared.scss
@@ -0,0 +1,511 @@
+/**
+ * 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/.
+ */
+
+/**
+ * Shared table, pill and print styling for the share reports.
+ *
+ * Layered on top of certificates.shared.scss, which already supplies the page frame
+ * (.cert-root / .cert-head / .cert-kpis / .cert-filters). Only what those do not cover lives here.
+ */
+
+$srep-border: #e2e8f0;
+$srep-border-soft: #f1f5f9;
+$srep-head-bg: #f8fafc;
+$srep-text: #0f172a;
+$srep-muted: #64748b;
+
+.srep-table-wrap {
+ overflow-x: auto;
+ border: 1px solid $srep-border;
+ border-radius: 10px;
+ background: #fff;
+}
+
+.srep-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.82rem;
+
+ th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: $srep-head-bg;
+ color: $srep-muted;
+ font-size: 0.68rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ text-align: left;
+ padding: 0.55rem 0.7rem;
+ border-bottom: 1px solid $srep-border;
+ white-space: nowrap;
+ }
+
+ td {
+ padding: 0.5rem 0.7rem;
+ border-bottom: 1px solid $srep-border-soft;
+ color: $srep-text;
+ vertical-align: middle;
+ }
+
+ tbody tr:last-child td {
+ border-bottom: none;
+ }
+
+ tbody tr.srep-row--click {
+ cursor: pointer;
+
+ &:hover {
+ background: #f4f7fb;
+ }
+ }
+
+ // A row that is over — closed, rejected, never activated — still belongs on the register, but it
+ // must not read like a live holding. Dimmed and marked at the edge rather than hidden, because a
+ // closed account that silently disappears is its own kind of confusion at a counter.
+ tbody tr.srep-row--muted {
+ background: #fafbfc;
+ color: $srep-muted;
+
+ td {
+ color: $srep-muted;
+ }
+
+ td:first-child {
+ box-shadow: inset 3px 0 0 #cbd5e1;
+ }
+ }
+
+ // Allotted but never collected. Flagged, not dimmed: this one is live and wants chasing.
+ tbody tr.srep-row--flag {
+ background: #fffdf5;
+
+ td:first-child {
+ box-shadow: inset 3px 0 0 #f59e0b;
+ }
+ }
+
+ .srep-num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ }
+
+ .srep-mono {
+ font-family: 'Courier New', monospace;
+ }
+
+ // A second line under a figure, for the qualifier the figure alone cannot carry — "50 unpaid"
+ // beneath a held count of nothing.
+ .srep-sub {
+ margin-top: 1px;
+ font-size: 0.68rem;
+ font-weight: 600;
+ color: #b45309;
+ }
+
+ tfoot td {
+ background: $srep-head-bg;
+ font-weight: 700;
+ border-top: 2px solid $srep-border;
+ border-bottom: none;
+ }
+}
+
+.srep-pill {
+ display: inline-block;
+ padding: 1px 9px;
+ border-radius: 999px;
+ font-size: 0.68rem;
+ font-weight: 700;
+ white-space: nowrap;
+
+ &.srep-pill--green {
+ background: #ecfdf5;
+ color: #059669;
+ border: 1px solid #a7f3d0;
+ }
+
+ &.srep-pill--amber {
+ background: #fffbeb;
+ color: #d97706;
+ border: 1px solid #fde68a;
+ }
+
+ &.srep-pill--red {
+ background: #fef2f2;
+ color: #dc2626;
+ border: 1px solid #fecaca;
+ }
+
+ &.srep-pill--blue {
+ background: #eff6ff;
+ color: #2563eb;
+ border: 1px solid #bfdbfe;
+ }
+
+ &.srep-pill--grey {
+ background: #f1f5f9;
+ color: #475569;
+ border: 1px solid #cbd5e1;
+ }
+}
+
+.srep-state {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 2rem 1rem;
+ color: $srep-muted;
+ font-size: 0.85rem;
+ text-align: center;
+}
+
+.srep-state--error {
+ color: #dc2626;
+}
+
+/* A narrowed view says so, right where the numbers are, so it is never read as the whole society. */
+.srep-scope-note {
+ margin-top: 0.5rem;
+ font-size: 0.75rem;
+ color: $srep-muted;
+}
+
+/* Account picker — results drop under the search box rather than into a dialog, so choosing an
+ account is one click from typing rather than a modal that has to be dismissed. */
+.srep-picker {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ z-index: 20;
+ margin-top: 2px;
+ max-height: 260px;
+ overflow-y: auto;
+ background: #fff;
+ border: 1px solid $srep-border;
+ border-radius: 8px;
+ box-shadow: 0 10px 24px -8px rgb(15 23 42 / 18%);
+}
+
+.srep-picker-item {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ width: 100%;
+ padding: 0.45rem 0.7rem;
+ background: none;
+ border: none;
+ border-bottom: 1px solid $srep-border-soft;
+ text-align: left;
+ cursor: pointer;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ &:hover {
+ background: #f4f7fb;
+ }
+}
+
+.srep-picker-name {
+ font-size: 0.82rem;
+ font-weight: 600;
+ color: $srep-text;
+}
+
+.srep-picker-meta {
+ font-size: 0.72rem;
+ color: $srep-muted;
+}
+
+/* Inline distribution bar — the shareholding pattern reads as a shape before it reads as numbers,
+ and a chart library for one bar per row would be a dependency for nothing. */
+.srep-bar {
+ width: 100%;
+ height: 8px;
+ background: $srep-border-soft;
+ border-radius: 999px;
+ overflow: hidden;
+}
+
+.srep-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #2563eb, #60a5fa);
+ border-radius: 999px;
+}
+
+/* Statement header — the identity block a printed statement has to carry. */
+.srep-stmt-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.85rem 1rem;
+ margin-bottom: 0.85rem;
+ background: #f8fafc;
+ border: 1px solid $srep-border;
+ border-radius: 10px;
+}
+
+.srep-stmt-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 0.6rem 1.2rem;
+ flex: 1;
+}
+
+.srep-stmt-l {
+ font-size: 0.66rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: $srep-muted;
+ font-weight: 700;
+}
+
+.srep-stmt-v {
+ font-size: 0.84rem;
+ color: $srep-text;
+ font-weight: 500;
+}
+
+/* A share class's own authorised-vs-issued fraction, read at a glance rather than only in the two
+ numbers beside it. */
+.product-utilisation-bar {
+ width: 100%;
+ min-width: 90px;
+ height: 6px;
+ border-radius: 999px;
+ background: $srep-border-soft;
+ overflow: hidden;
+}
+
+.product-utilisation-fill {
+ height: 100%;
+ border-radius: 999px;
+ background: linear-gradient(90deg, #2563eb, #059669);
+}
+
+/* Something the reader has to know before trusting the number above it — an exclusion, a gap, a
+ figure that will not reconcile until someone acts. Loud enough to be read, not an error. */
+.srep-warn {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.6rem;
+ margin: 0.6rem 0;
+ padding: 0.65rem 0.85rem;
+ background: #fffbeb;
+ border: 1px solid #fde68a;
+ border-left: 4px solid #d97706;
+ border-radius: 8px;
+ font-size: 0.82rem;
+ line-height: 1.55;
+ color: #92400e;
+}
+
+/* The selected tab, in a screen that has two halves rather than two pages. */
+.srep-tab--active {
+ background: #eff6ff !important;
+ border-color: #2563eb !important;
+ color: #1e40af !important;
+ font-weight: 700;
+}
+
+/* Printed certificate — a document, not a screen: it carries the society's letterhead, a body in
+ sentences and a signature block, because that is what a member is handed and files. */
+.spc-doc {
+ border: 1px solid $srep-border;
+ border-radius: 10px;
+ padding: 1.5rem 1.75rem;
+ background: #fff;
+}
+
+.spc-letterhead {
+ text-align: center;
+ border-bottom: 2px solid $srep-text;
+ padding-bottom: 0.75rem;
+ margin-bottom: 1.1rem;
+}
+
+.spc-org {
+ font-size: 1.15rem;
+ font-weight: 700;
+ color: $srep-text;
+ letter-spacing: 0.02em;
+}
+
+.spc-org-sub {
+ font-size: 0.78rem;
+ color: $srep-muted;
+ margin-top: 2px;
+}
+
+.spc-title {
+ margin-top: 0.7rem;
+ font-size: 0.95rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: $srep-text;
+}
+
+.spc-body {
+ font-size: 0.88rem;
+ line-height: 1.7;
+ color: $srep-text;
+ margin-bottom: 1rem;
+}
+
+.spc-words {
+ margin-top: 0.75rem;
+ font-size: 0.85rem;
+ color: $srep-text;
+}
+
+.spc-foot {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 2rem;
+ margin-top: 2.5rem;
+ font-size: 0.82rem;
+}
+
+.spc-sign {
+ text-align: center;
+ font-size: 0.8rem;
+ color: $srep-text;
+}
+
+.spc-sign-line {
+ width: 170px;
+ border-top: 1px solid $srep-text;
+ margin-bottom: 4px;
+}
+
+/* Label sheet — a grid sized to the label stationery a branch buys, printed straight from here. */
+.slab-sheet {
+ display: grid;
+ gap: 0.5rem;
+ margin-top: 0.6rem;
+}
+
+.slab {
+ border: 1px dashed $srep-border;
+ border-radius: 6px;
+ padding: 0.65rem 0.8rem;
+ min-height: 92px;
+ font-size: 0.82rem;
+ line-height: 1.45;
+ color: $srep-text;
+ break-inside: avoid;
+}
+
+.slab-name {
+ font-weight: 700;
+}
+
+.slab-line {
+ color: $srep-text;
+}
+
+.slab-meta {
+ margin-top: 3px;
+ font-size: 0.7rem;
+ color: $srep-muted;
+}
+
+/* One letter, one page. */
+.slet-page {
+ border: 1px solid $srep-border;
+ border-radius: 10px;
+ padding: 1.5rem 1.75rem;
+ margin-top: 0.85rem;
+ background: #fff;
+ break-after: page;
+}
+
+.slet-meta {
+ display: flex;
+ justify-content: space-between;
+ gap: 1.5rem;
+ font-size: 0.84rem;
+ line-height: 1.5;
+ margin-bottom: 1.1rem;
+}
+
+.slet-date {
+ white-space: nowrap;
+ color: $srep-muted;
+}
+
+.slet-subject {
+ font-weight: 700;
+ font-size: 0.86rem;
+ margin-bottom: 0.9rem;
+}
+
+.slet-salutation {
+ font-size: 0.86rem;
+ margin-bottom: 0.6rem;
+}
+
+.slet-body {
+ font-size: 0.86rem;
+ line-height: 1.75;
+ white-space: pre-wrap;
+ color: $srep-text;
+}
+
+.slet-body-input {
+ width: 100%;
+ padding: 0.55rem 0.7rem;
+ border: 1px solid $srep-border;
+ border-radius: 8px;
+ font-family: inherit;
+ font-size: 0.84rem;
+ line-height: 1.6;
+ color: $srep-text;
+ resize: vertical;
+}
+
+@media print {
+ .cert-head-r,
+ .cert-filters,
+ .srep-no-print {
+ display: none !important;
+ }
+
+ .slab {
+ border: 1px solid #999;
+ }
+
+ .slet-page {
+ border: none;
+ padding: 0;
+ margin: 0;
+ }
+
+ .spc-doc {
+ border: none;
+ padding: 0;
+ }
+
+ .srep-table-wrap {
+ overflow: visible;
+ border: none;
+ }
+
+ .srep-table th {
+ position: static;
+ }
+}
diff --git a/src/app/shares/reports/share-report.util.ts b/src/app/shares/reports/share-report.util.ts
new file mode 100644
index 000000000..4d609ef39
--- /dev/null
+++ b/src/app/shares/reports/share-report.util.ts
@@ -0,0 +1,185 @@
+/**
+ * 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/.
+ */
+
+/**
+ * Shared vocabulary and plumbing for the share reports.
+ *
+ * Kept in one place because the reports are read side by side — a member shown as "Active" on the
+ * balance report and "Approved" on the status report would read as two different things when it is
+ * one status_enum, and a CSV that quotes its fields on one screen but not the next breaks whichever
+ * spreadsheet opens both.
+ */
+
+/** m_share_account.status_enum → what a reader calls it, and the pill it wears. */
+export const SHARE_ACCOUNT_STATUS: { [key: number]: { label: string; cls: string } } = {
+ 100: { label: 'Pending Approval', cls: 'srep-pill--amber' },
+ 200: { label: 'Approved', cls: 'srep-pill--blue' },
+ 300: { label: 'Active', cls: 'srep-pill--green' },
+ 500: { label: 'Rejected', cls: 'srep-pill--red' },
+ 600: { label: 'Closed', cls: 'srep-pill--grey' }
+};
+
+/**
+ * Why a share account's holding reads the way it does — the server's own derivation, so every
+ * report tells the member the same story about the same account.
+ *
+ *
+ * A nil holding is the most over-loaded figure on a share register. A closed account, an active
+ * account whose money was never collected, and an account redeemed down to nothing all print the
+ * same "0", and a reader cannot tell them apart. Each state therefore carries its own pill, its own
+ * row treatment, and a sentence saying what it means — closed and finished rows are dimmed, and the
+ * one state that still wants chasing is flagged rather than greyed out.
+ */
+export const SHARE_HOLDING_STATE: {
+ [key: string]: { label: string; cls: string; row: string; note: string };
+} = {
+ HOLDING: {
+ label: 'Holding',
+ cls: 'srep-pill--green',
+ row: '',
+ note: 'Paid-up shares in hand.'
+ },
+ UNPAID: {
+ label: 'Allotted · unpaid',
+ cls: 'srep-pill--amber',
+ row: 'srep-row--flag',
+ note: 'Shares allotted, but the collection was never applied — so they are in no capital figure.'
+ },
+ NIL: {
+ label: 'Nil holding',
+ cls: 'srep-pill--grey',
+ row: 'srep-row--muted',
+ note: 'Active and paid for, but redeemed back down to nothing.'
+ },
+ CLOSED: {
+ label: 'Closed',
+ cls: 'srep-pill--grey',
+ row: 'srep-row--muted',
+ note: 'The account is closed — the nil holding is final and correct.'
+ },
+ NOT_ACTIVE: {
+ label: 'Not active',
+ cls: 'srep-pill--blue',
+ row: 'srep-row--muted',
+ note: 'Submitted or approved, not yet activated.'
+ },
+ REJECTED: {
+ label: 'Rejected',
+ cls: 'srep-pill--red',
+ row: 'srep-row--muted',
+ note: 'The application never became an account.'
+ }
+};
+
+/** The states in the order a reader thinks about them — live first, finished last. */
+export const SHARE_HOLDING_STATE_ORDER = [
+ 'HOLDING',
+ 'UNPAID',
+ 'NIL',
+ 'CLOSED',
+ 'NOT_ACTIVE',
+ 'REJECTED'
+];
+
+export function holdingStateLabel(state: string | null | undefined): string {
+ return SHARE_HOLDING_STATE[state || '']?.label || state || '—';
+}
+
+export function holdingStateClass(state: string | null | undefined): string {
+ return SHARE_HOLDING_STATE[state || '']?.cls || 'srep-pill--grey';
+}
+
+/** The row treatment: dimmed when the account is over, flagged when it still wants chasing. */
+export function holdingStateRowClass(state: string | null | undefined): string {
+ return SHARE_HOLDING_STATE[state || '']?.row || '';
+}
+
+export function holdingStateNote(state: string | null | undefined): string {
+ return SHARE_HOLDING_STATE[state || '']?.note || '';
+}
+
+/** m_share_account_transactions.type_enum — only the two that move shares. */
+export const SHARE_TXN_TYPE: { [key: number]: { label: string; cls: string } } = {
+ 500: { label: 'Purchase', cls: 'srep-pill--green' },
+ 600: { label: 'Redemption', cls: 'srep-pill--red' },
+ 700: { label: 'Charge Payment', cls: 'srep-pill--grey' }
+};
+
+/** m_share_account_transactions.status_enum — a tranche's own approval state. */
+export const SHARE_TXN_STATUS: { [key: number]: { label: string; cls: string } } = {
+ 100: { label: 'Pending', cls: 'srep-pill--amber' },
+ 300: { label: 'Approved', cls: 'srep-pill--green' },
+ 400: { label: 'Rejected', cls: 'srep-pill--red' }
+};
+
+/** How a tranche was paid for; blank on the opening tranche, which the account itself carries. */
+export function fundingLabel(source: string | null | undefined): string {
+ if (!source) {
+ return 'Opening';
+ }
+ return source === 'CASH' ? 'Cash Receipt' : source === 'SAVINGS' ? 'Savings Debit' : source;
+}
+
+/** m_share_account_dividend_details.status — where a member's dividend has got to. */
+export const SHARE_DIVIDEND_STATUS: { [key: number]: { label: string; cls: string } } = {
+ 100: { label: 'Declared', cls: 'srep-pill--amber' },
+ 300: { label: 'Approved', cls: 'srep-pill--blue' },
+ 600: { label: 'Paid', cls: 'srep-pill--green' }
+};
+
+/** Where a transfer stands in maker-checker. */
+export const SHARE_TRANSFER_STATUS: { [key: string]: { label: string; cls: string } } = {
+ PENDING: { label: 'Pending approval', cls: 'srep-pill--amber' },
+ APPROVED: { label: 'Approved', cls: 'srep-pill--green' },
+ REJECTED: { label: 'Rejected', cls: 'srep-pill--red' }
+};
+
+/**
+ * Fineract's period frequency enum, as a phrase.
+ *
+ * Reads "18 months" rather than "18 / 2", which is what the raw pair means to nobody outside the
+ * schema. An unset period says "None" rather than showing a bare zero that looks like a real term.
+ */
+export function periodLabel(value: number | null | undefined, frequencyEnum: number | null | undefined): string {
+ if (!value) {
+ return 'None';
+ }
+ const unit = { 0: 'day', 1: 'week', 2: 'month', 3: 'year' }[frequencyEnum ?? -1] || 'period';
+ return `${value} ${unit}${Number(value) === 1 ? '' : 's'}`;
+}
+
+/**
+ * Downloads rows as CSV.
+ *
+ * Every field is quoted and its own quotes doubled, so a member name carrying a comma — which is
+ * most of them, once an address or a title creeps in — cannot shift every later column by one.
+ */
+export function shareReportCsv(
+ filename: string,
+ headers: string[],
+ rows: (string | number | null | undefined)[][]
+): void {
+ const escape = (value: string | number | null | undefined): string => `"${String(value ?? '').replace(/"/g, '""')}"`;
+ const csv = [
+ headers.map(escape).join(','),
+ ...rows.map((row) => row.map(escape).join(','))
+ ].join('\n');
+
+ const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `${filename}.csv`;
+ link.click();
+ URL.revokeObjectURL(url);
+}
+
+/** The browser's own print, against the component's print stylesheet. */
+export function shareReportPrint(): void {
+ window.print();
+}
diff --git a/src/app/shares/reports/share-status-report/share-status-report.component.html b/src/app/shares/reports/share-status-report/share-status-report.component.html
new file mode 100644
index 000000000..85fd0ee19
--- /dev/null
+++ b/src/app/shares/reports/share-status-report/share-status-report.component.html
@@ -0,0 +1,230 @@
+
+
+
+
+
+
+
+
Share Status Report
+
+ Where every share account stands — by status and share class, with the accounts behind each figure.
+
+
+
+
+
+ Export CSV
+
+
+ Print
+
+
+
+
+ @if (totals) {
+
+
+
+
+
{{ totals.accountCount | number }}
+
Share Accounts
+
+
+
+
+
+
{{ totals.memberCount | number }}
+
Members
+
+
+
+
+
+
{{ totals.approvedShares | number }}
+
Shares Allotted
+
+
+
+
+
+
₹{{ totals.shareValue | number: '1.2-2' }}
+
Share Capital
+
+
+
+ }
+
+ @if (loading) {
+
Loading share status…
+ } @else if (loadError) {
+
{{ loadError }}
+ } @else {
+
+
+
+
+
+
Status
+
Accounts
+
Members
+
Shares Allotted
+
Share Value
+
+
+
+ @for (s of statusTotals; track s.statusEnum) {
+
+ {{ shares | number }} shares of {{ fromAccount.productName }} move from
+ {{ fromAccount.clientName }} ({{ fromAccount.accountNo }}) to {{ toAccount.clientName }} ({{
+ toAccount.accountNo
+ }}). Share capital does not change — only the register does. Nothing moves until a checker approves it.
+
diff --git a/src/app/shares/reports/share-transfer/share-transfer.component.ts b/src/app/shares/reports/share-transfer/share-transfer.component.ts
new file mode 100644
index 000000000..154d8aab2
--- /dev/null
+++ b/src/app/shares/reports/share-transfer/share-transfer.component.ts
@@ -0,0 +1,339 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { Subject, of } from 'rxjs';
+import { catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
+
+import { SharesService } from '../../shares.service';
+import { SettingsService } from 'app/settings/settings.service';
+import { SHARE_TRANSFER_STATUS, shareReportCsv, shareReportPrint } from '../share-report.util';
+
+/**
+ * Share Transfer — moving shares from one member to another, and the register of every transfer
+ * made.
+ *
+ *
+ * No money reaches the society and its share capital does not change: what changes is the register,
+ * one member's holding down and another's up. The society's part is to approve it, so the screen is
+ * a request form and a queue rather than an instant action — a transfer carries voting rights with
+ * it and is not one clerk's decision.
+ *
+ *
diff --git a/src/app/shares/reports/share-wise-report/share-wise-report.component.ts b/src/app/shares/reports/share-wise-report/share-wise-report.component.ts
new file mode 100644
index 000000000..3620b50bd
--- /dev/null
+++ b/src/app/shares/reports/share-wise-report/share-wise-report.component.ts
@@ -0,0 +1,167 @@
+/**
+ * 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, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+
+import { SharesService } from '../../shares.service';
+import { SettingsService } from 'app/settings/settings.service';
+import { shareReportCsv, shareReportPrint } from '../share-report.util';
+
+interface BandRow {
+ band: string;
+ bandOrder: number;
+ accountCount: number;
+ memberCount: number;
+ shares: number;
+ shareValue: number;
+ /** This band's percentage of total share capital, computed server-side against the true total. */
+ capitalShare: number;
+}
+
+interface ProductRow {
+ productId: number;
+ productName: string;
+ accountCount: number;
+ memberCount: number;
+ shares: number;
+ shareValue: number;
+}
+
+/**
+ * Share Wise Report — the shareholding pattern: how the society's capital is spread across its
+ * members, and how it splits by share class.
+ *
+ *
+ * Concentration is what this answers. A total and a member count cannot show whether three members
+ * hold half the capital, and that is the number a board and the Registrar both ask for.
+ *
+ *