- Already holds
- ₹{{ glTarget.toLocaleString('en-IN') }}
- — count the drawer to exactly this amount.
+ This branch has already opened today's business day — counting the drawer again here would only be rejected
+ on submit. Deposits, withdrawals and closing today's day happen from Day Register instead.
- } @else {
-
- No opening balance posted yet — whatever you count below becomes this account's opening balance.
-
- }
-
-
-
-
-
-
-
-
Denomination
-
Count
-
Amount
+
- @for (d of allDenoms; track d.id) {
-
-
- {{ d.label }} {{ d.kind }}
-
-
-
-
-
-
-
₹{{ subtotalFor(d).toLocaleString('en-IN') }}
-
- }
-
-
-
-
-
-
- Counted Total
- ₹{{ countedTotal.toLocaleString('en-IN') }}
+ @if (needsDifferenceReason) {
+
+
+
+ Why does the count differ from the GL balance?
+
+
+
+
+ }
+
+ @if (errorMsg) {
+
{{ errorMsg }}
+ }
+
+ @if (successMsg) {
+
+ check_circle
+
+ {{ successMsg }}
+
+
+
+
+
+ }
+
+ @if (!successMsg) {
+
+
+
+ }
}
}
diff --git a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss
index 6dcf31aa0..c68d75abd 100644
--- a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss
+++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.scss
@@ -115,6 +115,10 @@
color: #64748b;
line-height: 1.4;
}
+
+ button {
+ margin-top: 10px;
+ }
}
.mcd-grid {
@@ -171,7 +175,11 @@
}
&__input {
- width: 48px;
+ /* Width is set inline per row (see countInputWidthCh()), growing with the digit count typed —
+ min-width here only covers the fallback before Angular has bound anything. */
+ min-width: 48px;
+ max-width: 96px;
+ transition: width 0.1s ease-out;
text-align: center;
font-variant-numeric: tabular-nums;
border: 1px solid #e2e8f0;
diff --git a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts
index a66fb5651..2509a0fce 100644
--- a/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts
+++ b/src/app/accounting/migrate-cash-denomination/migrate-cash-denomination.component.ts
@@ -66,6 +66,14 @@ export class MigrateCashDenominationComponent implements OnInit {
glStatus: any = null;
glError = '';
+ /** Set once per office change: whether TODAY's business day is already open for this office. This
+ * screen is a one-time "day 1" tool (see the class doc) - once a day has actually been opened,
+ * reopening the same one is exactly what BranchDayRegisterApiResource#migrateCashDenomination
+ * itself refuses (its own openDayInternal call rejects a day that is already open), so this is
+ * checked here too, before the user counts an entire drawer only to have the submit rejected. */
+ todayAlreadyOpen = false;
+ todayOpenInfo: any = null;
+
notes: Denom[] = [];
coins: Denom[] = [];
counts: Record = {};
@@ -118,6 +126,8 @@ export class MigrateCashDenominationComponent implements OnInit {
this.result = null;
this.successMsg = '';
this.errorMsg = '';
+ this.todayAlreadyOpen = false;
+ this.todayOpenInfo = null;
if (!this.officeId) return;
this.glLoading = true;
@@ -131,6 +141,15 @@ export class MigrateCashDenominationComponent implements OnInit {
this.glError = res.message || 'No cash GL account is configured for this tenant yet.';
}
});
+
+ this.service
+ .branchDayRegister(this.officeId)
+ .pipe(catchError(() => of(null)))
+ .subscribe((res: any) => {
+ const openDay = res?.openDay;
+ this.todayAlreadyOpen = !!openDay && openDay.businessDate === res?.today;
+ this.todayOpenInfo = this.todayAlreadyOpen ? openDay : null;
+ });
}
countFor(d: Denom): number {
@@ -149,6 +168,12 @@ export class MigrateCashDenominationComponent implements OnInit {
this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta);
}
+ /** Widens the count box as the typed count grows more digits, so a large count (hundreds,
+ * thousands) is never clipped or hard to read inside a field sized for one or two digits. */
+ countInputWidthCh(d: Denom): number {
+ return Math.max(3, String(this.countFor(d)).length + 2);
+ }
+
get countedTotal(): number {
return this.allDenoms.reduce((s, d) => s + this.subtotalFor(d), 0);
}
diff --git a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html
index 728b7cf77..ac06c467a 100644
--- a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html
+++ b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.html
@@ -162,7 +162,18 @@
diff --git a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss
index 14cf2ca5a..578f9a0ee 100644
--- a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss
+++ b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.scss
@@ -219,6 +219,33 @@
word-break: break-word;
}
+ /* Cash at Vault / Cash at Teller — locked, see MigrateOpeningBalancesComponent.isDenominationManaged. */
+ &__lock {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin-left: 8px;
+ padding: 1px 8px 1px 6px;
+ border-radius: 999px;
+ background: #fff7ed;
+ color: #9a3412;
+ font-size: 11px;
+ font-weight: 600;
+ white-space: nowrap;
+ vertical-align: middle;
+
+ mat-icon {
+ font-size: 13px;
+ width: 13px;
+ height: 13px;
+ }
+
+ a {
+ color: inherit;
+ text-decoration: underline;
+ }
+ }
+
&__amt {
text-align: right;
}
diff --git a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts
index b1636d9df..0a4bcf291 100644
--- a/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts
+++ b/src/app/accounting/migrate-opening-balances/migrate-opening-balances.component.ts
@@ -17,6 +17,7 @@ import {
} from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
+import { MatTooltipModule } from '@angular/material/tooltip';
/** Custom Services */
import { AccountingService } from '../accounting.service';
@@ -43,7 +44,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
...STANDALONE_SHARED_IMPORTS,
GlAccountDisplayComponent,
CurrencyPipe,
- MatIconModule
+ MatIconModule,
+ MatTooltipModule
]
})
export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
@@ -74,6 +76,13 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
debitsSum = 0;
/** Sum total of credits. */
creditsSum = 0;
+ /** GL account ids mapped to Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) — the
+ * account(s) Migrate Cash Denomination owns. Their opening balance is shown here for visibility
+ * but locked against editing: redefining it as a bare number here, instead of through a counted
+ * denomination breakdown, would let the GL figure drift away from what is physically in the till.
+ * The backend enforces the same rule independently (see defineOpeningBalance's
+ * validateNoDenominationManagedCashAccounts) — this is a convenience, not the source of truth. */
+ denominationManagedGlAccountIds: number[] = [];
/* Reference of search form */
@ViewChild('searchFormRef') searchFormRef: ElementRef;
@@ -95,6 +104,22 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
this.officeData = data.offices;
this.currencyData = data.currencies.selectedCurrencyOptions;
});
+ // Office-independent: which GL account(s) this tenant has mapped to Cash at Vault / Cash at
+ // Teller, so their row can be locked the moment the office's accounts are drawn.
+ this.accountingService.getFinancialActivityAccounts().subscribe((mappings: any[]) => {
+ this.denominationManagedGlAccountIds = (mappings || [])
+ .filter((mapping) => mapping.financialActivityData?.id === 101 || mapping.financialActivityData?.id === 102)
+ .map((mapping) => mapping.glAccountData?.id)
+ .filter((id) => id != null);
+ // Retrieve may already have run before this (fast) request came back — lock any row it
+ // missed rather than leaving it editable for the rest of this visit.
+ this.glAccountEntries?.controls.forEach((control) => {
+ const glAccountId = control.get('glAccountId')?.value;
+ if (this.isDenominationManaged(glAccountId) && control.enabled) {
+ control.disable();
+ }
+ });
+ });
}
/**
@@ -136,14 +161,31 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
* @returns {FormGroup} GL Account entry form.
*/
createGLAccountEntryForm(glAccount: any): UntypedFormGroup {
- return this.formBuilder.group(
+ // A never-touched account arrives as a placeholder row (amount/entryType both null); one that
+ // already carries an opening balance arrives with its real, committed figure — show that
+ // instead of leaving the field looking blank.
+ const existingDebit = glAccount.entryType?.value === 'DEBIT' ? glAccount.amount : null;
+ const existingCredit = glAccount.entryType?.value === 'CREDIT' ? glAccount.amount : null;
+ const group = this.formBuilder.group(
{
glAccountId: [glAccount.glAccountId],
- debit: [null],
- credit: [null]
+ debit: [existingDebit],
+ credit: [existingCredit]
},
{ validator: onlyOneOfTheFieldsIsRequiredValidator }
);
+ if (this.isDenominationManaged(glAccount.glAccountId)) {
+ // Disabling excludes this row from the form's own .value, so it is never resubmitted here —
+ // the account keeps whatever Migrate Cash Denomination last set it to.
+ group.disable();
+ }
+ return group;
+ }
+
+ /** Whether this GL account is Cash at Vault / Cash at Teller — locked here, see
+ * denominationManagedGlAccountIds. */
+ isDenominationManaged(glAccountId: number): boolean {
+ return this.denominationManagedGlAccountIds.includes(glAccountId);
}
/**
@@ -176,17 +218,30 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
this.openingBalancesData = openingBalancesData;
- entry.valueChanges.subscribe(() => {
- this.debitsSum = 0;
- this.creditsSum = 0;
- entry.controls.forEach((value) => {
- this.debitsSum += value.value.debit;
- this.creditsSum += value.value.credit;
- });
- });
+ entry.valueChanges.subscribe(() => this.recomputeSums());
+ // Runs once immediately too: valueChanges only fires on a later edit, and the locked cash
+ // row's own prefilled figure (set once, above, never through a further value change) would
+ // otherwise never be reflected in the banner at all.
+ this.recomputeSums();
});
}
+ /** Total Debit / Total Credit shown in the balance banner. A locked (denomination-managed) row is
+ * excluded here exactly as it is excluded from the form's own .value used at submit time — this
+ * screen only needs to balance the accounts it is itself about to (re)define, not the one
+ * Migrate Cash Denomination already settled and this form can never touch. */
+ private recomputeSums(): void {
+ this.debitsSum = 0;
+ this.creditsSum = 0;
+ this.glAccountEntries.controls.forEach((control) => {
+ if (control.disabled) {
+ return;
+ }
+ this.debitsSum += control.value.debit || 0;
+ this.creditsSum += control.value.credit || 0;
+ });
+ }
+
/**
* Submits the opening balances form and defines opening balances,
* if successful redirects to view created transaction.
diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.html b/src/app/accounting/search-journal-entry/search-journal-entry.component.html
index f8dacebff..6938ce4bd 100644
--- a/src/app/accounting/search-journal-entry/search-journal-entry.component.html
+++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.html
@@ -53,10 +53,13 @@
Refresh
-
+
+ @if (!disabledFeatures.createJournalEntries) {
+
+ }
+ lock
+
+ This branch has never opened a business day, and its Cash at Vault GL account has no opening balance yet. Count
+ the vault and open the first business day together from
+ Migrate Cash Denomination — Open Day here is
+ locked until that is done, so today's cash tracking never starts out of step with the ledger.
+
+
- @if (d.cashDifference == null) {
+ @if (!hasValue(d.cashDifference)) {
—
} @else if (d.cashDifference === 0) {
Tallied
diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss
index fa49ed69d..c97aebcc9 100644
--- a/src/app/cashier/branch-day/branch-day.component.scss
+++ b/src/app/cashier/branch-day/branch-day.component.scss
@@ -30,6 +30,36 @@
}
}
+/* Blocks Open Day until the branch's first-ever open has gone through Migrate Cash Denomination —
+ see BranchDayComponent.checkFirstOpenCashMigration(). Same shape as .ve-note, in the error palette
+ the rest of this screen already uses for a missed close (.bd-missed). */
+.bd-locked {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 10px 14px;
+ margin-bottom: 14px;
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ border-radius: 8px;
+ color: #b91c1c;
+ font-size: 12px;
+ line-height: 1.5;
+
+ mat-icon {
+ flex: none;
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ }
+
+ a {
+ color: inherit;
+ font-weight: 600;
+ text-decoration: underline;
+ }
+}
+
.bd-success {
padding: 10px 14px;
margin-bottom: 14px;
diff --git a/src/app/cashier/branch-day/branch-day.component.ts b/src/app/cashier/branch-day/branch-day.component.ts
index 579a3364a..da476f658 100644
--- a/src/app/cashier/branch-day/branch-day.component.ts
+++ b/src/app/cashier/branch-day/branch-day.component.ts
@@ -10,7 +10,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
-import { ActivatedRoute } from '@angular/router';
+import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, map, of, switchMap } from 'rxjs';
import { CashierService } from '../cashier.service';
@@ -61,7 +61,8 @@ interface BranchDay {
imports: [
CommonModule,
FormsModule,
- MatIconModule
+ MatIconModule,
+ RouterLink
]
})
export class BranchDayComponent implements OnInit {
@@ -85,6 +86,12 @@ export class BranchDayComponent implements OnInit {
loaded = false;
errorMsg = '';
successMsg = '';
+ /** True only for a branch that has never opened a business day and whose Cash at Vault / Cash at
+ * Teller GL account has never had anything posted to it — Open Day here would let day-to-day cash
+ * tracking begin while the ledger silently stays wherever it already was. The backend enforces
+ * this independently too (see BranchDayRegisterApiResource#requireCashMigrationBeforeFirstEverOpen);
+ * this flag only steers the user to the right screen before they hit that rejection. */
+ firstOpenNeedsCashMigration = false;
// Open-day form
showOpenForm = false;
@@ -202,6 +209,7 @@ export class BranchDayComponent implements OnInit {
this.today = res?.today ?? '';
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
+ this.checkFirstOpenCashMigration();
});
this.service
@@ -212,9 +220,42 @@ export class BranchDayComponent implements OnInit {
});
}
+ /**
+ * Only relevant the moment this branch has never opened a business day before — every later
+ * open/close on this screen is untouched, so there is nothing to re-check once history exists.
+ */
+ private checkFirstOpenCashMigration(): void {
+ if (this.days.length > 0 || this.openDay) {
+ this.firstOpenNeedsCashMigration = false;
+ return;
+ }
+ this.service.cashGlStatus().subscribe({
+ next: (status: any) => {
+ // configured === false means this tenant has no Cash at Vault / Cash at Teller account
+ // mapped at all — nothing for this check to reconcile against, so it does not block.
+ this.firstOpenNeedsCashMigration = status?.configured !== false && !status?.hasBalance;
+ },
+ error: () => {
+ this.firstOpenNeedsCashMigration = false;
+ }
+ });
+ }
+
/** True when the branch has no open day at all, so today can be opened. */
get canOpenToday(): boolean {
- return this.canOperate && !this.openDay;
+ return this.canOperate && !this.openDay && !this.firstOpenNeedsCashMigration;
+ }
+
+ /**
+ * A still-open day's closing/book figures are dropped from the API response entirely (the
+ * backend's Gson instance omits a null map value rather than sending it as JSON null), so the
+ * field arrives as undefined, not null. Checking only `!== null` against that — which a stricter
+ * pass of this file made the mistake of doing — leaves undefined on the "has a value" branch,
+ * which then renders `'₹' + null` (the number pipe's own null output, string-concatenated) as the
+ * literal text "₹null". This checks for either, the way an open day actually shows up.
+ */
+ hasValue(value: number | null | undefined): boolean {
+ return value !== null && value !== undefined;
}
startOpen(): void {
diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.html b/src/app/cashier/closing-cash-report/closing-cash-report.component.html
index b6faf9d5c..c964fd540 100644
--- a/src/app/cashier/closing-cash-report/closing-cash-report.component.html
+++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.html
@@ -303,8 +303,8 @@
`,
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html
index 98ae9647e..de55bc0f0 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.html
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.html
@@ -178,7 +178,7 @@
(row.dbCr = 'Cr')
"
>
- @for (a of acTypOptions; track a) {
+ @for (a of acTypOptionsFor(row); track a) {
}
@@ -432,8 +432,14 @@
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
"
>
- post_add
- Fill DD Details & Send for Approval
+ {{
+ row.ddDetails ? 'edit_note' : 'post_add'
+ }}
+
+ {{
+ row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details'
+ }}
@@ -1529,7 +1535,7 @@
(row.dbCr = 'Cr')
"
>
- @for (a of acTypOptions; track a) {
+ @for (a of acTypOptionsFor(row); track a) {
}
@@ -1756,7 +1762,7 @@
"
>
post_add
- Fill DD Details & Send for Approval
+ Fill DD Details
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts
index 5a49e956d..2ea622557 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.ts
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts
@@ -57,6 +57,27 @@ interface TxnRow {
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
resolutionNumber?: string;
meetingDate?: string;
+ /**
+ * Beneficiary and leaflet captured in the DD dialog, held on the row until Save.
+ *
+ * The dialog used to submit the request itself, which left the worksheet's own Save button doing
+ * nothing for a DD row — the clerk filled the dialog, the request was already gone, and pressing
+ * Save afterwards had no effect. Filling in details and committing them are two different
+ * actions, so the dialog now only records what was entered and Save is what sends it.
+ */
+ ddDetails?: {
+ leafletId: number;
+ ddNumber: string;
+ beneficiaryName: string;
+ payableAtBranch: string;
+ payableAtCity: string;
+ /** Present only for a walk-in cash draft. */
+ purchaser?: { name: string; address: string; aadhaar: string };
+ /** Note-and-coin breakdown, cash drafts only. */
+ denominations?: { [key: string]: number };
+ fundingAccountId?: number;
+ fundingClientId?: number;
+ };
}
interface Denomination {
@@ -1617,6 +1638,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private lastLoadedAcNo = '';
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
cashVaultGlId: number | null = null;
+ /** The Cash Vault GL account's own code and name — the denomination breakdown for a manual GL
+ * entry has to be filed against THIS account, not whichever account the teller typed in, since
+ * that account is never the one physical notes actually moved through (see submitGlJournalEntry).
+ * Both resolved dynamically off whatever this tenant's own financial-activity mapping names as
+ * its cash/vault account (see loadCashVaultGl) — never a literal account name or code. */
+ cashVaultGlCode: string | null = null;
+ cashVaultGlName: string | null = null;
// Joint holder expanded details (keyed by index)
expandedHolder: number | null = null;
@@ -1671,6 +1699,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
'GL',
'LA'
];
+
+ /**
+ * 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.
+ */
+ private static readonly DD_CLERK_ACTYPS = [
+ 'SD',
+ 'CA'
+ ];
+
+ /**
+ * The account types this particular row may use.
+ *
+ * Every other row keeps the full list; only a clerk's DD row is narrowed, because that is the
+ * only row where the account type silently decides which funding flow runs.
+ */
+ acTypOptionsFor(row: TxnRow): string[] {
+ if (this.isClerk && row.typ === 'DD') {
+ return TransactionEntryComponent.DD_CLERK_ACTYPS;
+ }
+ return this.acTypOptions;
+ }
dbCrOptions = [
'Dr',
'Cr'
@@ -2230,12 +2285,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// panel never appears and the row would post as an ordinary entry with the charges silently
// dropped.
//
- // A clerk funds a draft through an account or an instrument instead, and their TXN TP. list no
- // longer offers cash, so defaulting to it would open the field on a value it cannot show.
- // isDdCashGlRow() still holds for them through AC.TP = GL.
+ // A clerk funds a draft by debiting a member's own account, so their row opens on savings and
+ // is debited, and GL is not on their AC.TP list at all (see acTypOptionsFor). Opening them on
+ // GL put the row on the cash path, which asks for a walk-in purchaser and a note-and-coin
+ // count that a clerk debiting an account does not have.
case 'DD':
- row.dbCr = 'Cr';
- row.acTyp = 'GL';
+ row.dbCr = this.isClerk ? 'Dr' : 'Cr';
+ row.acTyp = this.isClerk ? 'SD' : 'GL';
row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId();
row.chequeNo = '';
row.cheqDate = '';
@@ -2416,13 +2472,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
});
+ // Recorded on the row, not sent. Save is what commits the worksheet, and a DD row that
+ // submitted itself here left that button with nothing to do — so the clerk had no way to
+ // review the entered details, and no way to abandon them either.
dialogRef.afterClosed().subscribe((result) => {
if (!result) return;
- if (accountDebit) {
- this.submitAccountDebitDd(row, leaflet, result, fundingAccountId!, fundingClientId!);
- } else {
- this.submitWalkinCashDd(row, leaflet, result);
- }
+ row.ddDetails = {
+ leafletId: leaflet.id,
+ ddNumber: leaflet.ddNumber,
+ beneficiaryName: result.beneficiaryName,
+ payableAtBranch: result.payableAtBranch,
+ payableAtCity: result.payableAtCity,
+ purchaser: accountDebit
+ ? undefined
+ : {
+ name: result.walkinPurchaserName,
+ address: result.walkinPurchaserAddress,
+ aadhaar: result.walkinPurchaserAadhaar
+ },
+ fundingAccountId: accountDebit ? fundingAccountId! : undefined,
+ fundingClientId: accountDebit ? fundingClientId! : undefined
+ };
+ this.showAlert('success', 'DD Details Captured', [
+ `DD No. ${leaflet.ddNumber} · in favour of ${result.beneficiaryName}.`,
+ accountDebit
+ ? `₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} once a checker approves.`
+ : `₹${this.ddTotalPayableFor(row)} to be collected at the counter.`,
+ 'Press Save to send this draft for approval.'
+ ]);
});
},
error: (err) => {
@@ -2480,6 +2557,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
row.amount = null;
row.ddCharges = null;
row.description = '';
+ // Cleared with the row: leaving the captured details behind would let a second Save raise
+ // the same draft again against a leaflet that has already been used.
+ row.ddDetails = undefined;
},
error: (err) => {
this.saving = false;
@@ -2488,6 +2568,53 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
});
}
+ /**
+ * Sends the drafts on the worksheet for approval.
+ *
+ * Each row carries the beneficiary details the dialog captured; a row that was never filled in is
+ * refused by name rather than skipped, because a clerk who pressed Save expecting one draft and
+ * got none has no way of telling which row was missing.
+ */
+ private saveDdRows(ddRows: TxnRow[]): void {
+ const unfilled = ddRows.filter((r) => !r.ddDetails);
+ if (unfilled.length > 0) {
+ this.showAlert('error', 'DD Details Not Entered', [
+ unfilled.length === 1
+ ? 'One DD row has no beneficiary details yet.'
+ : `${unfilled.length} DD rows have no beneficiary details yet.`,
+ 'Use "Fill DD Details & Send for Approval" on each DD row first, then press Save.'
+ ]);
+ return;
+ }
+
+ for (const row of ddRows) {
+ const details = row.ddDetails!;
+ const leaflet = { id: details.leafletId, ddNumber: details.ddNumber };
+ if (this.isDdAccountDebitRow(row)) {
+ this.submitAccountDebitDd(
+ row,
+ leaflet,
+ {
+ beneficiaryName: details.beneficiaryName,
+ payableAtBranch: details.payableAtBranch,
+ payableAtCity: details.payableAtCity
+ },
+ details.fundingAccountId!,
+ details.fundingClientId!
+ );
+ } else {
+ this.submitWalkinCashDd(row, leaflet, {
+ beneficiaryName: details.beneficiaryName,
+ payableAtBranch: details.payableAtBranch,
+ payableAtCity: details.payableAtCity,
+ walkinPurchaserName: details.purchaser?.name,
+ walkinPurchaserAddress: details.purchaser?.address,
+ walkinPurchaserAadhaar: details.purchaser?.aadhaar
+ });
+ }
+ }
+ }
+
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
@@ -2557,6 +2684,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
row.amount = null;
row.ddCharges = null;
row.description = '';
+ // See the account-debit path: a second Save must not re-raise a draft already issued.
+ row.ddDetails = undefined;
this.clearDenominations();
},
error: (err) => {
@@ -3121,6 +3250,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
byName(/vault/i) ?? byName(/cash/i) ?? items.find((a: any) => a.glAccountData?.type?.value === 'ASSET');
if (found?.glAccountData?.id) {
this.cashVaultGlId = found.glAccountData.id;
+ this.cashVaultGlCode = found.glAccountData.glCode ?? null;
+ this.cashVaultGlName = found.glAccountData.name ?? found.financialActivityData?.name ?? null;
}
});
}
@@ -3189,7 +3320,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Step 1: search on the correct endpoint for this account type
this.http
.get(
- `${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}`
+ `${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}${cfg.extraListQueryParams ? `&${cfg.extraListQueryParams}` : ''}`
)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
@@ -3278,6 +3409,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
matchField: string;
detailParams: string;
supportsPagination: boolean;
+ /** Extra fixed query params (e.g. "usage=1") appended to every list-search request for this
+ * account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
+ * for a transaction; a Header can never receive a posting, so it should never be offered. */
+ extraListQueryParams?: string;
} {
const t = acTyp?.toUpperCase();
if (t === 'LA') {
@@ -3331,7 +3466,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
listQueryParam: 'searchParam',
matchField: 'glCode',
detailParams: '?fetchRunningBalance=true',
- supportsPagination: false
+ supportsPagination: false,
+ // Detail-only: a Header account is a pure grouping node and can never receive a
+ // transaction, so it must never be a selectable match here in the first place.
+ extraListQueryParams: 'usage=1'
};
}
if (t === 'CA' || t === 'CB') {
@@ -4361,6 +4499,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
onSave(): void {
+ // Demand drafts commit here like everything else on the worksheet. Checked first because a DD
+ // row shares its shape with the ordinary entries below — without this it would fall through and
+ // post as a plain transaction, with the draft never raised and the charges silently dropped.
+ const ddRows = this.rows.filter((r) => this.isDdRow(r));
+ if (ddRows.length > 0) {
+ this.saveDdRows(ddRows);
+ return;
+ }
+
// Clerk bank transfer — N DR savings/CA + N CR savings/CA, immediate execution. 'Fund Transfer'
// is the same flow under a second TYPE label — onTypChange() already defaults it identically to
// 'Enter Bank Transfer', so submission has to recognise both or a share redemption or bank
@@ -5641,11 +5788,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
count: this.countFor(d),
subtotal: this.subtotalFor(d)
}));
+ // Filed against the Cash Vault GL, not the account the teller selected on screen: that
+ // selected account (e.g. Sundry Creditors) is the other leg of the journal entry, and
+ // never the one whose physical notes actually changed hands. Tagging the breakdown to it
+ // instead of the vault is why this cash never reached the cashier/vault position.
this.http
.post('/cashier/gl-denomination', {
txnId: res.transactionId ?? res.resourceId,
- accountNo: this.accountDetails?.glCode ?? row.acNo,
- accountName: this.accountDetails?.name ?? '',
+ accountNo: this.cashVaultGlCode ?? row.acNo,
+ accountName: this.cashVaultGlName ?? 'Cash at Vault',
txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
txnDate: txnDateIso,
paymentTypeName: selectedPt?.name ?? 'Cash',
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
index dc8109323..dbe9d8b20 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html
@@ -969,6 +969,11 @@
createClientForm.get('alternateMobileNo')?.touched
) {
Enter valid 10-digit number (starts with 6-9)
+ } @else if (
+ createClientForm.get('alternateMobileNo')?.hasError('sameAsMobile') &&
+ createClientForm.get('alternateMobileNo')?.touched
+ ) {
+ Alternate number can't be the same as Mobile No
}
@@ -979,7 +984,11 @@
color="primary"
type="button"
class="verify-btn"
- [disabled]="readOnly || createClientForm.get('alternateMobileNo')?.value?.length !== 10"
+ [disabled]="
+ readOnly ||
+ createClientForm.get('alternateMobileNo')?.value?.length !== 10 ||
+ createClientForm.get('alternateMobileNo')?.hasError('sameAsMobile')
+ "
(click)="verifyAlternateDIP()"
>
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
index 5fafd6717..ed53470c4 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
@@ -25,7 +25,10 @@ import {
UntypedFormGroup,
Validators,
UntypedFormControl,
- ReactiveFormsModule
+ ReactiveFormsModule,
+ ValidatorFn,
+ AbstractControl,
+ ValidationErrors
} from '@angular/forms';
import { Subject, Subscription, merge } from 'rxjs';
import { filter, switchMap, takeUntil } from 'rxjs/operators';
@@ -922,6 +925,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
}
}
+ /** Alternate Mobile No must differ from Mobile No — the same number saved in both fields is
+ * meaningless (there's no second contact route) and the DIP checks would just repeat. */
+ private notSameAsMobileNoValidator(): ValidatorFn {
+ return (control: AbstractControl): ValidationErrors | null => {
+ const value = control.value;
+ const mobileNo = control.parent?.get('mobileNo')?.value;
+ return value && mobileNo && value === mobileNo ? { sameAsMobile: true } : null;
+ };
+ }
+
/**
* Creates the client form.
*/
@@ -961,7 +974,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
'',
[
Validators.pattern(/^[6-9]\d{9}$/),
- Validators.maxLength(10)
+ Validators.maxLength(10),
+ this.notSameAsMobileNoValidator()
]
],
emailAddress: [
@@ -1334,6 +1348,10 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// invalid) and its own reset never had a chance to run.
this.mobileOtpVerified = false;
this.mobileNewOtpId = null;
+ // Alternate Mobile No's "not same as Mobile No" validator reads mobileNo's current
+ // value, so re-run it here too — otherwise fixing mobileNo away from a clash leaves
+ // the alternate field's error stuck until the user also edits that field.
+ this.createClientForm.get('alternateMobileNo')?.updateValueAndValidity({ emitEvent: false });
});
this.createClientForm
diff --git a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html
index c1922f22c..4daf63941 100644
--- a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html
+++ b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html
@@ -221,6 +221,22 @@
}
+
+ Expiry date (optional)
+ event_busy
+
+
+
+
}
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 101a3e073..0f4520920 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
@@ -1681,3 +1681,129 @@
.kyc-doc-scans-link__go {
margin-left: auto;
}
+
+/* The scan itself, beside the number and dates it belongs to. */
+.kyc-doc-scans {
+ margin-top: 10px;
+ padding: 12px;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ background: #fff;
+}
+
+.kyc-doc-scans__head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 10px;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ color: #475569;
+
+ mat-icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ color: #64748b;
+ }
+}
+
+.kyc-doc-scans__empty {
+ margin-bottom: 10px;
+ font-size: 12.5px;
+ color: #64748b;
+}
+
+.kyc-doc-scans__sides {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 14px;
+}
+
+.kyc-doc-side {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ width: 190px;
+}
+
+.kyc-doc-side__label {
+ font-size: 11.5px;
+ font-weight: 700;
+ color: #334155;
+}
+
+/* Fixed height so front and back line up whatever the scan's aspect ratio is. */
+.kyc-doc-side__frame {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 118px;
+ padding: 6px;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ background: #f8fafc;
+ overflow: hidden;
+
+ img {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+ border-radius: 3px;
+ }
+}
+
+.kyc-doc-side__none {
+ font-size: 12px;
+ color: #94a3b8;
+}
+
+.kyc-doc-side__file {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ padding: 0 6px;
+ font-size: 11px;
+ color: #475569;
+ text-align: center;
+ word-break: break-word;
+
+ mat-icon {
+ color: #64748b;
+ }
+}
+
+.kyc-doc-side__btn {
+ font-size: 12px;
+
+ mat-progress-spinner {
+ display: inline-block;
+ margin-right: 6px;
+ vertical-align: middle;
+ }
+}
+
+.kyc-doc-scans__msg {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 10px;
+ font-size: 12.5px;
+
+ mat-icon {
+ font-size: 17px;
+ width: 17px;
+ height: 17px;
+ }
+
+ &--err {
+ color: #b91c1c;
+ }
+
+ &--ok {
+ color: #15803d;
+ }
+}
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 365e9c0a7..32587d1e9 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
@@ -11,7 +11,7 @@ import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
-import { catchError, of, Subject, takeUntil } from 'rxjs';
+import { catchError, forkJoin, map, of, Subject, takeUntil } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
@@ -30,6 +30,11 @@ import {
AadhaarReceiptDialogComponent,
AadhaarReceiptResult
} from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component';
+import {
+ KycDocumentUpdateDialogComponent,
+ KycDocumentUpdateData,
+ KycDocumentUpdateResult
+} from '../../shared/kyc-document-update-dialog/kyc-document-update-dialog.component';
import { Dates } from 'app/core/utils/dates';
import { jsPDF } from 'jspdf';
import { MatIcon } from '@angular/material/icon';
@@ -38,6 +43,15 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+/** One side of the scanned KYC document, with the preview once it has been fetched. */
+interface DocScanSide {
+ documentId: string;
+ /** The identifier row this scan hangs off; the two sides can sit on different rows. */
+ identifierId: string;
+ fileName: string;
+ previewUrl: string | null;
+}
+
/**
* KYC Tab Component.
* Displays and manages KYC (Know Your Customer) details for an existing client.
@@ -149,9 +163,42 @@ export class KycTabComponent implements OnInit, OnDestroy {
exceptionReason: [[]]
});
+ // ── Officially valid document scans ────────────────────────────────────────
+
+ /**
+ * The scans of the document named above, by side.
+ *
+ * The screen used to show the document's number and dates but not the image behind them, and
+ * sent the officer to the client edit screen to change it. Re-collecting an expiring document is
+ * the one thing this panel exists for, so the scan is shown and replaced here.
+ */
+ docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null };
+
+ /** The two sides, in display order, so the template loops rather than repeating itself. */
+ readonly docScanSlots: ('front' | 'back')[] = [
+ 'front',
+ 'back'
+ ];
+
+ docScanOf(slot: 'front' | 'back') {
+ return this.docScans[slot];
+ }
+
+ /** Identifier row replacements are filed against. Null when the document has no identifier. */
+ private docIdentifierId: string | null = null;
+ /** Every identifier row carrying this document, since the sides can be split across them. */
+ private docIdentifierIds: string[] = [];
+ docScansLoading = false;
+ /** Which side is mid-upload, so only that button shows a spinner. */
+ docScanUploading: 'front' | 'back' | null = null;
+ docScanError: string | null = null;
+ docScanSuccess: string | null = null;
+
ngOnInit(): void {
this.route.parent.params.subscribe((params: any) => {
this.clientId = params['clientId'];
+ // Scans are matched against the document number on the KYC record, so they are loaded once
+ // that record has arrived rather than alongside it.
this.loadKycData();
this.loadClientDateOfBirth();
});
@@ -190,6 +237,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
});
+ // Now that the document number is known, the scans filed under it can be found.
+ this.loadDocumentScans();
}
});
}
@@ -1307,6 +1356,305 @@ I further confirm that:
doc.save(`Self_Declaration_${cifNumber}_${clientName.replace(/\s/g, '_')}.pdf`);
}
+ // ── Document scans ─────────────────────────────────────────────────────────
+
+ /**
+ * Finds the identifier the KYC document was filed under and loads its scans.
+ *
+ * The KYC record keeps the document's name and number but not the identifier id, so the two are
+ * matched on the number — that is what makes them the same document, and it survives the client
+ * holding several identifiers of the same type.
+ */
+ private loadDocumentScans(): void {
+ if (!this.clientId) {
+ return;
+ }
+ this.docScansLoading = true;
+ this.clientsService
+ .getClientIdentifiers(this.clientId)
+ .pipe(catchError(() => of([] as any[])))
+ .subscribe((identifiers: any[]) => {
+ const wantedKey = (this.kycData?.kycDocumentNumber || '').toString().trim();
+ const wantedName = (this.kycData?.kycDocumentName || '').toString().trim().toLowerCase();
+ const norm = (s: any) => (s || '').toString().trim().toLowerCase();
+
+ // Every identifier carrying this document number, not just the first. The onboarding step
+ // files each side against its own identifier row, so this client's Aadhaar front sits on
+ // one row and the back on another under the same number - taking only the first match
+ // showed the front and reported the back as missing.
+ let matches = (identifiers || []).filter((i) => wantedKey && norm(i.documentKey) === norm(wantedKey));
+ if (!matches.length) {
+ matches = (identifiers || []).filter((i) => wantedName && norm(i.documentType?.name) === wantedName);
+ }
+
+ if (!matches.length) {
+ this.docScansLoading = false;
+ this.docIdentifierId = null;
+ this.docIdentifierIds = [];
+ this.docScans = { front: null, back: null };
+ return;
+ }
+ // Replacements are filed against the first row, so the sides stay together from here on.
+ this.docIdentifierId = String(matches[0].id);
+ this.docIdentifierIds = matches.map((m: any) => String(m.id));
+ this.fetchScansFor(this.docIdentifierIds);
+ });
+ }
+
+ /**
+ * Sides are read off the document name, which is how they were written at upload
+ * (" — Front" / "— Back"). Where the name says nothing, upload order stands in, front
+ * first, rather than leaving a scan the officer cannot see.
+ */
+ private fetchScansFor(identifierIds: string[]): void {
+ forkJoin(
+ identifierIds.map((id) =>
+ this.clientsService.getClientIdentificationDocuments(id).pipe(
+ // Tagged with the row it came from, because the download call needs the parent id and
+ // the sides can be spread across more than one.
+ map((docs: any) => ((docs as any[]) || []).map((d) => ({ ...d, identifierId: id }))),
+ catchError(() => of([] as any[]))
+ )
+ )
+ ).subscribe((perIdentifier: any[][]) => {
+ this.docScansLoading = false;
+ // This runs again after every replace, so the previews being thrown away are revoked
+ // rather than left holding their blobs.
+ for (const old of [
+ this.docScans.front,
+ this.docScans.back
+ ]) {
+ if (old?.previewUrl) {
+ URL.revokeObjectURL(old.previewUrl);
+ }
+ }
+
+ // Oldest first by document id, so the newest upload for a side is the last one written and
+ // therefore the one that stands. Identifier order cannot do this job: a replacement is filed
+ // against the first row while the original sits on the second, so reading the rows in order
+ // let a document uploaded months ago overwrite the scan collected a moment ago - the officer
+ // pressed Replace, saw "replaced", and the old image came straight back.
+ const docs = perIdentifier.flat().sort((a, b) => Number(a.id) - Number(b.id));
+ const sides: typeof this.docScans = { front: null, back: null };
+ const unlabelled: any[] = [];
+
+ const entryOf = (d: any): DocScanSide => ({
+ documentId: String(d.id),
+ identifierId: String(d.identifierId),
+ fileName: d.fileName || d.name,
+ previewUrl: null
+ });
+
+ for (const d of docs) {
+ const text = `${d.name || ''} ${d.fileName || ''}`.toLowerCase();
+ // Later uploads win, so a replaced side shows the new scan rather than the original.
+ if (text.includes('back')) {
+ sides.back = entryOf(d);
+ } else if (text.includes('front')) {
+ sides.front = entryOf(d);
+ } else {
+ unlabelled.push(d);
+ }
+ }
+ for (const d of unlabelled) {
+ if (!sides.front) {
+ sides.front = entryOf(d);
+ } else if (!sides.back) {
+ sides.back = entryOf(d);
+ }
+ }
+
+ this.docScans = sides;
+ for (const key of [
+ 'front',
+ 'back'
+ ] as const) {
+ if (sides[key]) {
+ this.loadScanPreview(sides[key]!);
+ }
+ }
+ });
+ }
+
+ private loadScanPreview(side: DocScanSide): void {
+ this.clientsService
+ .downloadClientIdentificationDocument(side.identifierId, side.documentId)
+ .pipe(catchError(() => of(null)))
+ .subscribe((blob: Blob | null) => {
+ if (!blob || !blob.type?.startsWith('image/')) {
+ return;
+ }
+ // Revoked in ngOnDestroy; a preview left behind holds the blob in memory for the life of
+ // the tab.
+ side.previewUrl = URL.createObjectURL(blob);
+ });
+ }
+
+ /**
+ * Replaces one side with a freshly collected scan.
+ *
+ * The new file is uploaded under the same side name, which is what the readers here and on the
+ * client screen key on, so the latest upload becomes the scan on show. The old file is left in
+ * place deliberately: a KYC document that has been re-collected is a record of what was held
+ * before and when it changed, and destroying it would leave the audit trail with a gap.
+ */
+ onDocScanSelected(event: Event, slot: 'front' | 'back'): void {
+ const input = event.target as HTMLInputElement;
+ const file = input.files?.[0];
+ // Cleared so choosing the same filename twice still fires a change event.
+ input.value = '';
+ if (!file || !this.docIdentifierId) {
+ return;
+ }
+
+ this.docScanError = null;
+ this.docScanSuccess = null;
+ this.docScanUploading = slot;
+
+ const label = slot === 'front' ? 'Front' : 'Back';
+ const docName = this.kycForm.get('kycDocumentName')?.value || this.kycData?.kycDocumentName || 'KYC document';
+
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('name', `${docName} — ${label}`);
+ formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`);
+
+ this.clientsService.uploadClientIdentifierDocument(this.docIdentifierId, formData).subscribe({
+ next: () => {
+ this.docScanUploading = null;
+ this.docScanSuccess = `${label} side replaced.`;
+ // Re-read every row, not just the one written to: the other side still lives on its own.
+ this.fetchScansFor(this.docIdentifierIds);
+ },
+ error: (err: any) => {
+ this.docScanUploading = null;
+ this.docScanError =
+ err?.error?.defaultUserMessage ||
+ err?.error?.developerMessage ||
+ `Could not replace the ${label.toLowerCase()} side.`;
+ }
+ });
+ }
+
+ /**
+ * A verified record only accepts an amendment with a reason on it, so the dialog has to ask for
+ * one. 300 is VERIFIED; the code form is checked too, since either can arrive on the payload.
+ */
+ private get isKycVerified(): boolean {
+ return this.kycData?.kycStatus === 300 || this.kycData?.kycStatusCode === 'clientKycStatus.verified';
+ }
+
+ /**
+ * Re-collects the officially valid document from its own row.
+ *
+ * PAN and Aadhaar are each handled on their row; this one sent the officer into edit mode on the
+ * whole KYC form to do the same job. The dialog asks for the number, the dates and each side of
+ * the scan together, which is what re-collecting a document actually is.
+ */
+ updateKycDocument(): void {
+ if (!this.kycId) {
+ this.docScanError = 'KYC record not found. Save KYC details first.';
+ return;
+ }
+
+ this.dialog
+ .open(KycDocumentUpdateDialogComponent, {
+ width: '640px',
+ maxWidth: '95vw',
+ disableClose: true,
+ data: {
+ documentName: this.kycData?.kycDocumentName || 'KYC document',
+ documentNumber: this.kycData?.kycDocumentNumber || '',
+ issueDate: this.parseApiDate(this.kycData?.kycDocIssueDate),
+ expiryDate: this.parseApiDate(this.kycData?.kycDocExpiryDate),
+ reason: this.showKycDocumentAlert ? this.kycDocumentAlertMessage : '',
+ currentFront: this.docScans.front?.previewUrl || null,
+ currentBack: this.docScans.back?.previewUrl || null,
+ requiresRemarks: this.isKycVerified
+ } as KycDocumentUpdateData
+ })
+ .afterClosed()
+ .subscribe((result: KycDocumentUpdateResult | undefined) => {
+ if (result) {
+ this.persistKycDocument(result);
+ }
+ });
+ }
+
+ /**
+ * Saves the corrected details, then uploads whichever sides were re-scanned.
+ *
+ * The details go first: they are the record, and a scan attached to a document whose number was
+ * never corrected would file the new image under the old identity.
+ */
+ private persistKycDocument(result: KycDocumentUpdateResult): void {
+ this.saving = true;
+ this.docScanError = null;
+ this.docScanSuccess = null;
+
+ const dateFormat = 'dd MMMM yyyy';
+ const payload: any = {
+ locale: 'en',
+ dateFormat,
+ kycDocumentName: this.kycData?.kycDocumentName || null,
+ kycDocumentNumber: result.documentNumber || null,
+ kycDocIssueDate: result.issueDate ? this.dateUtils.formatDate(result.issueDate, dateFormat) : null,
+ kycDocExpiryDate: result.expiryDate ? this.dateUtils.formatDate(result.expiryDate, dateFormat) : null
+ };
+ // The server refuses an amendment to a verified record without one, and records it as the
+ // reason the document changed.
+ if (result.remarks) {
+ payload.remarks = result.remarks;
+ }
+
+ this.clientsService.updateClientKyc(this.clientId, this.kycId!, payload).subscribe({
+ next: () => {
+ this.saving = false;
+ this.docScanSuccess = 'Document updated.';
+ this.loadKycData();
+ this.uploadReplacedSides(result);
+ },
+ error: (err: any) => {
+ this.saving = false;
+ this.docScanError =
+ err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not update the document.';
+ }
+ });
+ }
+
+ /** Sends each re-scanned side through the same upload path the panel below uses. */
+ private uploadReplacedSides(result: KycDocumentUpdateResult): void {
+ if (!this.docIdentifierId) {
+ return;
+ }
+ const sides: { slot: 'front' | 'back'; file: File }[] = [];
+ if (result.front) {
+ sides.push({ slot: 'front', file: result.front });
+ }
+ if (result.back) {
+ sides.push({ slot: 'back', file: result.back });
+ }
+ if (!sides.length) {
+ return;
+ }
+
+ const docName = this.kycData?.kycDocumentName || 'KYC document';
+ forkJoin(
+ sides.map(({ slot, file }) => {
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('name', `${docName} — ${slot === 'front' ? 'Front' : 'Back'}`);
+ formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`);
+ return this.clientsService
+ .uploadClientIdentifierDocument(this.docIdentifierId!, formData)
+ .pipe(catchError(() => of(null)));
+ })
+ ).subscribe(() => {
+ this.docScanSuccess = 'Document and scan updated.';
+ this.fetchScansFor(this.docIdentifierIds);
+ });
+ }
+
/** Handle file selection for signed declaration upload */
onDeclarationFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
@@ -1365,5 +1713,14 @@ I further confirm that:
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
+ // Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab.
+ for (const side of [
+ this.docScans.front,
+ this.docScans.back
+ ]) {
+ if (side?.previewUrl) {
+ URL.revokeObjectURL(side.previewUrl);
+ }
+ }
}
}
diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts
index 6218ea5ac..ccd05c057 100644
--- a/src/app/clients/create-client/create-client.component.ts
+++ b/src/app/clients/create-client/create-client.component.ts
@@ -840,6 +840,15 @@ export class CreateClientComponent {
this.saveAddressProof(clientId);
}
+ /** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention the CKYC tab and
+ * client-india-address-step's own "load existing" parsing both read back. */
+ private buildProofDescription(issueDate: string | null | undefined, expiryDate: string | null | undefined): string {
+ const parts: string[] = [];
+ if (issueDate) parts.push(`Issue date: ${issueDate}`);
+ if (expiryDate) parts.push(`Expiry date: ${expiryDate}`);
+ return parts.join(', ');
+ }
+
/**
* Save the address-proof picked on the permanent tab as a client identifier
* + upload each selected file as a document attached to that identifier.
@@ -855,9 +864,9 @@ export class CreateClientComponent {
const proofName = payload.proof?.name || 'Address Proof';
const identifierData: any = {
documentTypeId: payload.proofId,
- status: 'Active',
+ status: 'ACTIVE',
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
- description: payload.issueDate ? `Issue date: ${payload.issueDate}` : ''
+ description: this.buildProofDescription(payload.issueDate, payload.expiryDate)
};
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
@@ -904,7 +913,7 @@ export class CreateClientComponent {
}
const identifierData: any = {
documentTypeId,
- status: 'Active',
+ status: 'ACTIVE',
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
description: 'KYC document'
};
diff --git a/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts b/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts
index 7d01f68d9..99cbd5959 100644
--- a/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts
+++ b/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts
@@ -164,6 +164,13 @@ export class ContactOtpBlockComponent implements OnChanges, OnDestroy {
request$.subscribe({
next: (res: any) => {
this.sending = false;
+ // The backend reports failure to actually dispatch the OTP (gateway error, flagged
+ // number, SMTP failure...) as a 200 response with success:false and a real reason in
+ // `message` — never a thrown error. Surface that reason instead of treating it as sent.
+ if (res?.success === false) {
+ this.errorMessage = res?.message || 'Could not send OTP.';
+ return;
+ }
this.otpId = res?.otpId ?? res?.resourceId ?? null;
this.otpSent = true;
this.clearAllDigits();
diff --git a/src/app/clients/edit-client/edit-client.component.ts b/src/app/clients/edit-client/edit-client.component.ts
index 0f300376a..b2a07e089 100644
--- a/src/app/clients/edit-client/edit-client.component.ts
+++ b/src/app/clients/edit-client/edit-client.component.ts
@@ -1035,6 +1035,15 @@ export class EditClientComponent {
this.saveAddressProof(clientId);
}
+ /** "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" — same convention the CKYC tab and
+ * client-india-address-step's own "load existing" parsing both read back. */
+ private buildProofDescription(issueDate: string | null | undefined, expiryDate: string | null | undefined): string {
+ const parts: string[] = [];
+ if (issueDate) parts.push(`Issue date: ${issueDate}`);
+ if (expiryDate) parts.push(`Expiry date: ${expiryDate}`);
+ return parts.join(', ');
+ }
+
/**
* Save the address-proof picked on the permanent tab as a client identifier
* + upload each selected file as a document attached to that identifier.
@@ -1050,9 +1059,9 @@ export class EditClientComponent {
const proofName = payload.proof?.name || 'Address Proof';
const identifierData: any = {
documentTypeId: payload.proofId,
- status: 'Active',
+ status: 'ACTIVE',
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
- description: payload.issueDate ? `Issue date: ${payload.issueDate}` : ''
+ description: this.buildProofDescription(payload.issueDate, payload.expiryDate)
};
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
@@ -1105,7 +1114,7 @@ export class EditClientComponent {
const existingId = this.clientKycStep?.existingKycIdentifierId;
if (existingId) {
this.resolveDocumentTypeId(payload.proofId, proofName).subscribe((documentTypeId: number | null) => {
- const update: any = { documentKey: payload.documentKey || `${proofName} - ${clientId}`, status: 'Active' };
+ const update: any = { documentKey: payload.documentKey || `${proofName} - ${clientId}`, status: 'ACTIVE' };
if (documentTypeId != null) {
update.documentTypeId = documentTypeId;
}
@@ -1128,7 +1137,7 @@ export class EditClientComponent {
}
const identifierData: any = {
documentTypeId,
- status: 'Active',
+ status: 'ACTIVE',
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
description: 'KYC document'
};
diff --git a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html
new file mode 100644
index 000000000..bf720407f
--- /dev/null
+++ b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html
@@ -0,0 +1,131 @@
+
+
+
+ description
+ Update {{ data.documentName }}
+
+
+
+
+ @if (data.reason) {
+
+ schedule
+ {{ data.reason }}
+
+ }
+
+
+
+
+
Scanned copy
+
+ Leave a side untouched to keep the scan already on file. The previous copy is kept either way.
+
+
+ No CTR has been generated yet. Pick a month under “Generate CTR” below and run it — every cash transaction
+ of ₹10,00,000 or more, and every customer whose smaller cash transactions add up to that within the month,
+ will be picked up from the books.
+
- Generate the FINnet STR XML, upload it on the FIU-IND portal under
- {{ bank.principalOfficer }}'s digital signature, then record the acknowledgement number here.
-
- @if (!canConcludeLocal) {
-
-
- Complete all three steps to determine the outcome.
- @if (!evidenceRecorded) {
- Evidence not yet recorded.
- }
- @if (!allAnswered) {
- {{ strChecklist.length - answeredCount }} verification point(s) pending.
- }
-
- } @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
-
-
- Source of funds established. Case closes as clean — the transaction stays in
- the CTR report and no STR is filed with FIU-IND.
-
- } @else {
-
-
- Source of funds not established. {{ failedFindings.length }} point(s)
- unsatisfied — an STR will be filed with FIU-IND in addition to the CTR.
-
- }
-
}
diff --git a/src/app/fiu/fiu.component.scss b/src/app/fiu/fiu.component.scss
index dbac07b0c..f8f0390c8 100644
--- a/src/app/fiu/fiu.component.scss
+++ b/src/app/fiu/fiu.component.scss
@@ -1086,6 +1086,17 @@
flex-shrink: 0;
margin-top: 2px;
}
+
+ /* Nothing to file yet, or the lines could not be read — not the neutral blue of an explanation. */
+ &--warn {
+ background: #fffbeb;
+ border-color: #fde68a;
+ color: #92400e;
+
+ fa-icon {
+ color: #d97706;
+ }
+ }
}
/* TOAST */
diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts
index c2dc8081b..25f51ebcf 100644
--- a/src/app/fiu/fiu.component.ts
+++ b/src/app/fiu/fiu.component.ts
@@ -13,17 +13,14 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { AmlService } from 'app/aml/aml.service';
import { SanctionsLogsService } from 'app/reports/sanctions-logs/sanctions-logs.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
+import { toApiDate, toIsoDate } from 'app/core/utils/api-date';
import {
BRANCHES,
BranchMaster,
CardTransaction,
CtrTransaction,
- CTR_TXNS,
- TC1_TXNS,
FIU_BANK,
FIU_BRANCH_CODE_TO_CANONICAL,
- KC1_INDIVIDUALS,
- KC2_ENTITIES,
KycEntity,
KycIndividual,
PoFinding,
@@ -127,7 +124,7 @@ export const FIU_OBLIGATIONS: FiuObligation[] = [
]
})
export class FiuComponent implements OnInit {
- /** Bump when CTR_TXNS / STR_TXNS change so cached rows are re-seeded. */
+ /** Bump when the seeded STR set changes so cached rows are re-seeded. */
private static readonly SEED_VERSION = '2026-01-cash-threshold';
private amlService = inject(AmlService);
@@ -140,11 +137,33 @@ export class FiuComponent implements OnInit {
readonly obligations = FIU_OBLIGATIONS;
bank = FIU_BANK;
- ctrTxns: CtrTransaction[] = CTR_TXNS;
- tc1Txns: CardTransaction[] = TC1_TXNS;
+ /**
+ * The CTR lines the bank has actually generated. Empty until the engine has produced a report —
+ * a filing screen that shows invented accounts is worse than one that shows none, because every
+ * count, total and CSV on it is then something nobody can submit.
+ */
+ ctrTxns: CtrTransaction[] = [];
+ ctrLinesLoading = false;
+ ctrLinesError = '';
+ /**
+ * ATM / card-rail cash, which feeds the same CTR as the counter transactions in TC2.
+ *
+ * Empty: the sample rows that used to sit here belonged to the sample accounts in TC2, and with
+ * those gone they referenced accounts this bank does not hold — twelve rows claiming to reconcile
+ * against a file that had none. Card transactions are not yet sourced from the books, so the file
+ * is honestly empty rather than populated with rows that cannot be filed.
+ */
+ tc1Txns: CardTransaction[] = [];
strTxns: StrTransaction[] = STR_TXNS;
- kc1: KycIndividual[] = KC1_INDIVIDUALS;
- kc2: KycEntity[] = KC2_ENTITIES;
+ /**
+ * KC1 / KC2 — the customer cards that travel with the CTR bundle.
+ *
+ * Empty for the same reason as TC1: these described sample customers of sample accounts. A card
+ * naming a person this bank has never onboarded cannot be filed, and leaving them in would put
+ * invented customers into a bundle an officer might submit.
+ */
+ kc1: KycIndividual[] = [];
+ kc2: KycEntity[] = [];
// -------------------- Head Office vs Branch scope --------------------
// CTR/STR filing itself is a bank-wide Principal-Officer function (one filing for the whole
@@ -155,6 +174,12 @@ export class FiuComponent implements OnInit {
readonly branches = BRANCHES;
isHeadOffice = false;
currentOfficeName = 'Branch Office';
+ /** The month being filed, from the clock rather than a constant in the data file. */
+ reportingMonthLabel = '';
+ /** Blank unless the signed-in user actually holds the Principal Officer role. */
+ principalOfficerName = '';
+ /** CTR is due by the 15th of the month after the one being reported. */
+ ctrDueDateLabel = '';
private ownBranchCode: string | null = null;
/** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */
branchFilter = '';
@@ -372,6 +397,21 @@ export class FiuComponent implements OnInit {
const creds = this.authenticationService.getCredentials();
this.isHeadOffice = this.authenticationService.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office';
+
+ // 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.
+ const today = new Date();
+ this.reportingMonthLabel = today.toLocaleDateString('en-IN', { month: 'long', year: 'numeric' });
+ // The 15th of the following month, which is the PMLA deadline for the month being reported.
+ this.ctrDueDateLabel = new Date(today.getFullYear(), today.getMonth() + 1, 15).toLocaleDateString('en-IN', {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric'
+ });
+ this.principalOfficerName = this.authenticationService.isPrincipalOfficerUser()
+ ? creds?.staffDisplayName || creds?.username || 'Principal Officer'
+ : '';
if (this.isHeadOffice) {
this.branchFilter = '';
} else {
@@ -384,22 +424,17 @@ export class FiuComponent implements OnInit {
this.loadAmlAlerts();
this.loadAmlCtrReports();
this.loadAmlStrCases();
+ // The CTR grids are built from the reports the bank generated, not from a sample set. Loaded
+ // after the cached rows below so a fresh read always wins over whatever was stored last.
+ this.loadCtrLines();
// Bumping SEED_VERSION discards previously cached rows so a new data set takes effect.
if (localStorage.getItem('fiu_seed_version') !== FiuComponent.SEED_VERSION) {
localStorage.setItem('fiu_seed_version', FiuComponent.SEED_VERSION);
- localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
return;
}
- const storedCtr = localStorage.getItem('fiu_ctr_txns');
- if (storedCtr) {
- this.ctrTxns = JSON.parse(storedCtr);
- } else {
- localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
- }
-
const storedStr = localStorage.getItem('fiu_str_cases');
if (storedStr) {
this.strTxns = JSON.parse(storedStr);
@@ -442,7 +477,7 @@ export class FiuComponent implements OnInit {
txn.evidenceDocType = this.ctrEvidenceDocType;
txn.evidenceReference = this.ctrEvidenceRef.trim();
txn.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
- localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
+ this.storeCtrCompliance();
this.evidencingTxn = null;
this.toast(`${txn.customerName} — evidence recorded. Reported in CTR only, no STR referral.`);
}
@@ -453,7 +488,7 @@ export class FiuComponent implements OnInit {
*/
referCtrTxnToStr(txn: CtrTransaction): void {
txn.complianceStatus = 'REFERRED';
- localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
+ this.storeCtrCompliance();
const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1;
const newStrCase: StrTransaction = {
@@ -521,7 +556,7 @@ export class FiuComponent implements OnInit {
origin.evidenceDocType = this.evidenceDocType;
origin.evidenceReference = this.evidenceReference.trim();
origin.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
- localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
+ this.storeCtrCompliance();
}
this.toast(`STR-${rec.reportRefNo} closed as clean — CTR only, no STR filed`);
} else {
@@ -598,6 +633,130 @@ export class FiuComponent implements OnInit {
this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || []));
}
+ /**
+ * Fineract sends timestamps as [y, M, d, H, m, s]. Angular's date pipe renders an array as
+ * nothing, which left "Raised On" blank beside alerts that had obviously been raised.
+ */
+ asDate(value: any): Date | null {
+ return toApiDate(value);
+ }
+
+ /**
+ * Loads the generated CTR lines and shapes them into the TC2 rows every grid on this tab derives
+ * from — Account_Detail, Account_Person_Relation and TC1 are all computed off `ctrTxns`, so the
+ * one conversion here puts real data behind all four files.
+ */
+ loadCtrLines(): void {
+ this.ctrLinesLoading = true;
+ this.ctrLinesError = '';
+ this.amlService.getCtrReportLines().subscribe({
+ next: (lines) => {
+ this.ctrLinesLoading = false;
+ this.ctrTxns = (lines || []).map((l: any, i: number) => this.toCtrTransaction(l, i + 1));
+ // Cached so the compliance flags an officer sets below survive a reload, keyed on the rows
+ // that actually came back rather than on a seeded set.
+ this.applyStoredCtrCompliance();
+ },
+ error: (err: any) => {
+ this.ctrLinesLoading = false;
+ this.ctrTxns = [];
+ this.ctrLinesError =
+ err?.error?.defaultUserMessage ||
+ 'Could not load the generated CTR lines. Generate a report for the month first.';
+ }
+ });
+ }
+
+ /**
+ * One reportable line as a TC2 row.
+ *
+ * A connected-group line is a month of smaller cash movements by one customer that together cross
+ * the threshold, so it has no single transaction, account or date behind it. It is still
+ * reportable, and is shown as the aggregate it is rather than dropped.
+ */
+ private toCtrTransaction(line: any, refNo: number): CtrTransaction {
+ const branchCode = this.branchCodeOfName(line.branchName) || '';
+ return {
+ reportType: 'CTR',
+ reportRefNo: refNo,
+ branchCode,
+ accountNumber: line.accountNo || '',
+ txnBranchCode: branchCode,
+ depositOrWithdrawal: line.direction === 'Withdrawal' ? 'Withdrawal' : 'Deposit',
+ txnId: line.transactionId || line.id,
+ txnDate: toIsoDate(line.transactionDate),
+ txnTime: '',
+ txnAmount: Number(line.amount) || 0,
+ instrumentType: 'Cash',
+ instrumentId: 'CASH',
+ nonCustomerRef: '',
+ relationshipFlag: 'Customer',
+ thirdPartyPan: '',
+ declaration: line.connectedGroup ? 'Connected transactions aggregated for the month' : '',
+ customerName: line.clientName || '',
+ complianceStatus: 'PENDING',
+ accountOpenedOn: toIsoDate(line.accountOpenedOn),
+ accountClosedOn: toIsoDate(line.accountClosedOn),
+ accountStatus: line.accountStatus || '',
+ ckycNumber: line.ckycNumber || '',
+ clientExternalId: line.clientExternalId || '',
+ individual: line.individual !== false
+ };
+ }
+
+ /** Branch master code for an office name, so the branch filter on this tab still works. */
+ private branchCodeOfName(name: string | null): string | null {
+ if (!name) {
+ return null;
+ }
+ const wanted = name.trim().toLowerCase();
+ return this.branches.find((b: BranchMaster) => wanted.includes(b.name.toLowerCase()))?.code || null;
+ }
+
+ /**
+ * Re-applies the evidence an officer recorded against these transactions.
+ *
+ * The rows themselves come from the server every load; only the counter-side compliance fields
+ * are held locally, so they are matched back on transaction id rather than by replacing the row.
+ */
+ private applyStoredCtrCompliance(): void {
+ const stored = localStorage.getItem('fiu_ctr_compliance');
+ if (!stored) {
+ return;
+ }
+ try {
+ const byTxnId: Record = JSON.parse(stored);
+ for (const t of this.ctrTxns) {
+ const saved = byTxnId[String(t.txnId)];
+ if (saved) {
+ t.complianceStatus = saved.complianceStatus;
+ t.evidenceDocType = saved.evidenceDocType;
+ t.evidenceReference = saved.evidenceReference;
+ t.evidencedOn = saved.evidencedOn;
+ }
+ }
+ } catch {
+ // A corrupted cache must not take the filing screen down with it.
+ localStorage.removeItem('fiu_ctr_compliance');
+ }
+ }
+
+ /** Persists only the compliance fields, keyed on transaction id. */
+ private storeCtrCompliance(): void {
+ const byTxnId: Record = {};
+ for (const t of this.ctrTxns) {
+ if (t.complianceStatus && t.complianceStatus !== 'PENDING') {
+ byTxnId[String(t.txnId)] = {
+ complianceStatus: t.complianceStatus,
+ evidenceDocType: t.evidenceDocType,
+ evidenceReference: t.evidenceReference,
+ evidencedOn: t.evidencedOn
+ };
+ }
+ }
+ localStorage.setItem('fiu_ctr_compliance', JSON.stringify(byTxnId));
+ }
+
/** Surfaces a failed backend call — without this a rejected request looks like a dead button. */
private apiError(action: string) {
return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`);
@@ -654,6 +813,9 @@ export class FiuComponent implements OnInit {
next: () => {
this.toast(`CTR report generated for ${this.ctrGenerateMonth}`);
this.loadAmlCtrReports();
+ // The grids above are built from these lines, so they are re-read rather than left showing
+ // the state before the run.
+ this.loadCtrLines();
},
error: this.apiError('CTR report generation')
});
@@ -1016,7 +1178,9 @@ export class FiuComponent implements OnInit {
t.txnBranchCode,
t.depositOrWithdrawal,
t.txnId.toString(),
- t.txnDate.replace(/-/g, '/'),
+ // dd/MM/yyyy, as FINnet prints it. A straight slash-swap on an ISO date gave 2026/09/11,
+ // which reads as a different date to anyone expecting the prescribed format.
+ this.toFinnetDate(t.txnDate),
t.txnTime,
t.txnAmount.toString(),
t.instrumentType,
@@ -1254,6 +1418,19 @@ export class FiuComponent implements OnInit {
return { headers, rows };
}
+ /** FINnet prints dates as dd/MM/yyyy; the API sends them as yyyy-MM-dd. Blank stays blank. */
+ private toFinnetDate(iso: string | undefined): string {
+ if (!iso) {
+ return '';
+ }
+ const [
+ y,
+ m,
+ d
+ ] = iso.split('-');
+ return y && m && d ? `${d}/${m}/${y}` : '';
+ }
+
/** Account_Detail — per-report account aggregates (12-month) */
private buildAccountDetail(): FinnetTable {
const headers = [
@@ -1290,12 +1467,15 @@ export class FiuComponent implements OnInit {
rows.push([
t.reportType,
t.reportRefNo.toString(),
+ // 212 is FINnet's savings-account code; every CTR line this module produces is a savings
+ // transaction, and a loan line would need its own code before it could be filed.
'212',
t.accountNumber,
t.branchCode,
- '20/05/2018',
- '',
- 'ACTIVE',
+ // Read off the account, not assumed. Every row used to carry 20/05/2018 and ACTIVE.
+ this.toFinnetDate(t.accountOpenedOn),
+ this.toFinnetDate(t.accountClosedOn),
+ t.accountStatus || '',
'',
debits.length.toString(),
debits.reduce((s, x) => s + x.txnAmount, 0).toString(),
@@ -1333,16 +1513,16 @@ export class FiuComponent implements OnInit {
const key = `${t.reportRefNo}|${t.accountNumber}`;
if (seen.has(key)) continue;
seen.add(key);
- // Find matching KC1 by reportRefNo (best-effort dummy mapping)
- const k = this.kc1.find((x) => x.reportRefNo === t.reportRefNo);
rows.push([
t.reportType,
t.reportRefNo.toString(),
t.branchCode,
t.accountNumber,
'1',
- k ? k.ucic : '0',
- 'Individual',
+ // The customer's own reference: their CKYC number where one is on file, else the external
+ // id the bank knows them by. This used to read '0' for every row, which identified nobody.
+ t.ckycNumber || t.clientExternalId || '',
+ t.individual === false ? 'Non-individual' : 'Individual',
t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : ''
]);
}
@@ -1488,14 +1668,58 @@ export class FiuComponent implements OnInit {
}
/** STR XML stub (FINnet 2.0 STR XML) */
+ /**
+ * Writes the STR XML from the cases the Principal Officer has actually concluded as suspicious.
+ *
+ * It used to be built from the sample set, so the file named accounts and customers this bank
+ * does not hold — a return that could not be filed and should not have been offered.
+ */
exportStrXml(): void {
- const suspects = this.strSuspects;
+ const suspects = (this.amlStrCases || []).filter(
+ (c: any) => c.conclusion === 'SUSPICIOUS' || c.status === 'CONCLUDED_SUSPICIOUS' || c.status === 'FILED'
+ );
if (!suspects.length) {
- this.toast('No suspect cases to export — nothing matches the current filters');
+ this.toast('No case has been concluded suspicious yet — nothing to file');
return;
}
- this.writeStrXml(suspects, `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`);
- this.toast(`STR XML exported (${suspects.length} suspects)`);
+ this.writeLiveStrXml(suspects, `STR-${this.reportingMonthLabel.replace(/\s+/g, '-')}.xml`);
+ this.toast(`STR XML exported (${suspects.length} case${suspects.length === 1 ? '' : 's'})`);
+ }
+
+ /** Serialises the concluded cases as FINnet 2.0 STR XML and downloads it. */
+ private writeLiveStrXml(cases: any[], filename: string): void {
+ const xml: string[] = [];
+ xml.push('');
+ xml.push(
+ ``
+ );
+ xml.push(` `);
+ if (this.principalOfficerName) {
+ xml.push(` `);
+ }
+ for (const c of cases) {
+ xml.push(` `);
+ xml.push(` ${this.escapeXml(c.clientDisplayName || '')}`);
+ xml.push(` ${this.escapeXml(c.conclusion || '')}`);
+ if (c.investigationNotes) {
+ xml.push(` ${this.escapeXml(c.investigationNotes)}`);
+ }
+ if (c.sourceOfFundsNotes) {
+ xml.push(` ${this.escapeXml(c.sourceOfFundsNotes)}`);
+ }
+ if (c.fiuReferenceNo) {
+ xml.push(` `);
+ }
+ xml.push(' ');
+ }
+ xml.push('');
+ const blob = new Blob([xml.join('\n')], { type: 'application/xml;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
}
/** Serialises a set of suspect cases as FINnet 2.0 STR XML and downloads it. */
diff --git a/src/app/letter-mgmt/certificates/certificate-pdf.ts b/src/app/letter-mgmt/certificates/certificate-pdf.ts
index 91aebd362..ddf2fb004 100644
--- a/src/app/letter-mgmt/certificates/certificate-pdf.ts
+++ b/src/app/letter-mgmt/certificates/certificate-pdf.ts
@@ -25,10 +25,68 @@ import { Form15Kind, Form15ghRecord } from './form-15gh/form-15gh.component';
import { HypothecationRecord } from './hypothecation/hypothecation.component';
import { NocRecord } from './noc/noc.component';
-const BANK_NAME = 'Nexora Co-operative Bank Ltd.';
-const BANK_ADDRESS = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051';
-const BANK_REG = 'RBI Lic. No. NX-2018/MH/482';
-const BANK_CONTACT = 'info@nexorabank.in · 022 6789 0000';
+/**
+ * The issuing bank's own identity, as set on the Institution Profile (Admin > Organization) — this
+ * file is shared by every certificate type (loan interest, share, balance, Form 60/15G/15H,
+ * hypothecation, NOC) and every tenant this app runs for, so nothing here can be a fixed name.
+ * These defaults only cover the moment before whichever screen opened this module has fetched the
+ * real profile and called {@link setBankIdentity}; they are never meant to reach a printed PDF.
+ */
+const bankIdentity = {
+ name: 'Bank',
+ address: '',
+ regNo: '',
+ contact: '',
+ /** City/state/country line used by the Share Certificate's own letterhead style. */
+ location: '',
+ /** Short label for the circular seal graphic — two lines, e.g. bank's short name + type. */
+ sealPrimary: 'BANK',
+ sealSecondary: ''
+};
+
+/** Called once the caller has fetched the real Institution Profile, so every certificate built
+ * after this point — of any type — carries the actual bank's own details. */
+export function setBankIdentity(profile: {
+ bankName?: string;
+ statementHeaderName?: string;
+ shortName?: string;
+ bankType?: string;
+ addressLine1?: string;
+ addressLine2?: string;
+ city?: string;
+ state?: string;
+ country?: string;
+ licenseNumber?: string;
+ contactEmail?: string;
+ contactPhone?: string;
+}): void {
+ bankIdentity.name = profile.statementHeaderName || profile.bankName || bankIdentity.name;
+ bankIdentity.address = [
+ profile.addressLine1,
+ profile.addressLine2,
+ profile.city
+ ]
+ .filter((p) => !!p && String(p).trim())
+ .join(', ');
+ bankIdentity.regNo = profile.licenseNumber ? `RBI Lic. No. ${profile.licenseNumber}` : '';
+ bankIdentity.contact = [
+ profile.contactEmail,
+ profile.contactPhone
+ ]
+ .filter((p) => !!p && String(p).trim())
+ .join(' · ');
+ bankIdentity.location = [
+ profile.city,
+ profile.state,
+ profile.country
+ ]
+ .filter((p) => !!p && String(p).trim())
+ .join(', ');
+ bankIdentity.sealPrimary = (profile.shortName || profile.bankName || bankIdentity.name).toUpperCase();
+ bankIdentity.sealSecondary = profile.bankType
+ ? profile.bankType.replace(/_/g, ' ').toUpperCase() + (/BANK$/i.test(profile.bankType) ? '' : ' BANK')
+ : '';
+}
const C_TEXT: [number, number, number] = [
15,
@@ -141,14 +199,24 @@ function paintLetterhead(
c.doc.setFont('times', 'bold');
c.doc.setFontSize(19);
setText(c, c.accent);
- c.doc.text(BANK_NAME, c.pageW / 2, y, { align: 'center' });
+ c.doc.text(bankIdentity.name, c.pageW / 2, y, { align: 'center' });
y += 5.5;
c.doc.setFont('helvetica', 'normal');
c.doc.setFontSize(9);
setText(c, C_MUTED);
- c.doc.text(BANK_ADDRESS, c.pageW / 2, y, { align: 'center' });
- y += 4;
- c.doc.text(`${BANK_REG} · ${BANK_CONTACT}`, c.pageW / 2, y, { align: 'center' });
+ if (bankIdentity.address) {
+ c.doc.text(bankIdentity.address, c.pageW / 2, y, { align: 'center' });
+ y += 4;
+ }
+ const regContactLine = [
+ bankIdentity.regNo,
+ bankIdentity.contact
+ ]
+ .filter((p) => !!p)
+ .join(' · ');
+ if (regContactLine) {
+ c.doc.text(regContactLine, c.pageW / 2, y, { align: 'center' });
+ }
y += 5;
setDraw(c, C_BORDER);
c.doc.setLineWidth(0.3);
@@ -350,11 +418,10 @@ function paintSignoff(c: PdfCtx, y: number, branch: string): number {
c.doc.setFont('helvetica', 'bold');
c.doc.setFontSize(7);
setText(c, c.accent);
- c.doc.text('NEXORA', cx, cy - 1, { align: 'center' });
+ c.doc.text(bankIdentity.sealPrimary, cx, cy - 1, { align: 'center' });
c.doc.setFontSize(5);
setText(c, C_MUTED);
- c.doc.text('CO-OPERATIVE', cx, cy + 3, { align: 'center' });
- c.doc.text('BANK', cx, cy + 5.5, { align: 'center' });
+ c.doc.text(bankIdentity.sealSecondary, cx, cy + 4, { align: 'center' });
return y + 28;
}
@@ -376,7 +443,7 @@ function paintFooter(c: PdfCtx, y: number): void {
c.doc.setFontSize(8);
setText(c, C_MUTED);
c.doc.text(
- `Issued by ${BANK_NAME} on ${formatLongDate(today())}. Computer generated — does not require physical signature for record purposes.`,
+ `Issued by ${bankIdentity.name} on ${formatLongDate(today())}. Computer generated — does not require physical signature for record purposes.`,
c.pageW / 2,
y,
{ align: 'center' }
@@ -609,13 +676,15 @@ export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
doc.setFont('times', 'bold');
doc.setFontSize(22);
doc.setTextColor(darkBlue[0], darkBlue[1], darkBlue[2]);
- doc.text('NEXORA CO-OPERATIVE BANK LTD.', pageW / 2, y, { align: 'center' });
+ doc.text(bankIdentity.name.toUpperCase(), pageW / 2, y, { align: 'center' });
- y += 5;
- doc.setFont('times', 'italic');
- doc.setFontSize(10.5);
- doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
- doc.text('Mumbai, Maharashtra, India', pageW / 2, y, { align: 'center' });
+ if (bankIdentity.location) {
+ y += 5;
+ doc.setFont('times', 'italic');
+ doc.setFontSize(10.5);
+ doc.setTextColor(textMuted[0], textMuted[1], textMuted[2]);
+ doc.text(bankIdentity.location, pageW / 2, y, { align: 'center' });
+ }
y += 4;
doc.setDrawColor(242, 237, 213); // light gold separator
@@ -685,7 +754,7 @@ export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
doc.setFontSize(11);
doc.setTextColor(textDark[0], textDark[1], textDark[2]);
- const bodyText = `This is to certify that ${r.memberName} is the registered holder of ${r.shareCount.toLocaleString('en-IN')} (${classLabel}) shares of Rs. ${r.faceValue.toLocaleString('en-IN')} each, fully paid up, in Nexora Co-operative Bank Ltd., subject to the memorandum, articles of association, and bye-laws of the bank.`;
+ const bodyText = `This is to certify that ${r.memberName} is the registered holder of ${r.shareCount.toLocaleString('en-IN')} (${classLabel}) shares of Rs. ${r.faceValue.toLocaleString('en-IN')} each, fully paid up, in ${bankIdentity.name}, subject to the memorandum, articles of association, and bye-laws of the bank.`;
const bodyLines = doc.splitTextToSize(bodyText, pageW - 50);
for (const line of bodyLines) {
doc.text(line, pageW / 2, y, { align: 'center' });
@@ -763,7 +832,7 @@ export function buildShareCertificatePdf(r: ShareCertificateRecord): jsPDF {
// Row 1
drawTableCell(25, y, tableColW, tableRowH, 'Branch', true);
- drawTableCell(25 + tableColW, y, tableColW, tableRowH, r.branch || 'Mumbai Main Branch', false);
+ drawTableCell(25 + tableColW, y, tableColW, tableRowH, r.branch || 'Head Office', false);
drawTableCell(25 + tableColW * 2, y, tableColW, tableRowH, 'Member Since', true);
drawTableCell(25 + tableColW * 3, y, tableColW, tableRowH, formatLongDate(r.joiningDate), false);
y += tableRowH;
@@ -1168,7 +1237,7 @@ export function buildHypothecationPdf(r: HypothecationRecord): jsPDF {
y = paintPara(
c,
y,
- `This is to certify that the asset described below stands hypothecated to ${BANK_NAME} as security for the captioned loan facility extended to ${r.customerName}, and the bank holds the first and exclusive charge over the said asset until the loan is fully discharged.`
+ `This is to certify that the asset described below stands hypothecated to ${bankIdentity.name} as security for the captioned loan facility extended to ${r.customerName}, and the bank holds the first and exclusive charge over the said asset until the loan is fully discharged.`
);
const rows: [string, string, string, string][] = [
diff --git a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.html b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.html
index a0b6381fd..022a216f3 100644
--- a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.html
+++ b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.html
@@ -684,9 +684,9 @@
-
NEXORA
+
{{ bankSealPrimary }}
-
CO-OPERATIVE BANK
+
{{ bankSealSecondary }}
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 f099d25a1..13b5f7636 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
@@ -575,8 +575,19 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
-/* ───────── Print — only the page, no toolbar / dialog chrome ───────── */
+/* ───────── 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. */
@media print {
+ body > :not(.cdk-overlay-container) {
+ display: none !important;
+ }
+
:host {
background: #fff;
}
@@ -585,11 +596,29 @@
display: none !important;
}
+ .cdk-overlay-container,
+ .cdk-global-overlay-wrapper,
+ .cdk-overlay-pane,
+ .mat-mdc-dialog-container,
+ .mdc-dialog__surface {
+ position: static !important;
+ overflow: visible !important;
+ max-height: none !important;
+ height: auto !important;
+ max-width: none !important;
+ box-shadow: none !important;
+ padding: 0 !important;
+ }
+
.cpd-root {
+ position: fixed;
+ left: 0;
+ top: 0;
+ width: 100% !important;
+ height: auto !important;
background: #fff;
max-height: none;
overflow: visible;
- height: auto;
}
.cpd-page {
@@ -599,6 +628,7 @@
width: 100%;
box-shadow: none;
border-top: 4px solid var(--cpd-blue-d);
+ overflow: visible;
&.cpd-page--share {
border-top-color: var(--cpd-amber);
diff --git a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.ts b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.ts
index 0484c415d..8b35a57d5 100644
--- a/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.ts
+++ b/src/app/letter-mgmt/certificates/certificate-preview-dialog/certificate-preview-dialog.component.ts
@@ -6,12 +6,13 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-import { Component, inject } from '@angular/core';
+import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { OrganizationService } from 'app/organization/organization.service';
import { FyInterestBreakup, LoanInterestRecord } from '../loan-interest/loan-interest.component';
import { ShareCertificateRecord } from '../share-certificate/share-certificate.component';
@@ -27,7 +28,8 @@ import {
downloadHypothecationPdf,
downloadLoanInterestPdf,
downloadNocPdf,
- downloadShareCertificatePdf
+ downloadShareCertificatePdf,
+ setBankIdentity
} from '../certificate-pdf';
export interface InterestPreviewPayload {
@@ -70,14 +72,52 @@ export interface CertificatePreviewDialogData {
MatTooltip
]
})
-export class CertificatePreviewDialogComponent {
+export class CertificatePreviewDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef);
+ private organizationService = inject(OrganizationService);
data: CertificatePreviewDialogData = inject(MAT_DIALOG_DATA);
- readonly bankName = 'Nexora Co-operative Bank Ltd.';
- readonly bankAddress = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051';
- readonly bankRegNo = 'RBI Lic. No. NX-2018/MH/482';
- readonly bankContact = 'info@nexorabank.in · 022 6789 0000';
+ /** The issuing bank's own details, fetched from the Institution Profile — this dialog is shared
+ * by every certificate type and every tenant, so nothing here is a fixed name. */
+ bankName = 'Bank';
+ bankAddress = '';
+ bankRegNo = '';
+ bankContact = '';
+ bankSealPrimary = 'BANK';
+ bankSealSecondary = '';
+
+ ngOnInit(): void {
+ this.organizationService.getInstitutionProfile().subscribe({
+ next: (profile: any) => {
+ this.bankName = profile?.statementHeaderName || profile?.bankName || this.bankName;
+ this.bankAddress = [
+ profile?.addressLine1,
+ profile?.addressLine2,
+ profile?.city
+ ]
+ .filter((p: any) => !!p && String(p).trim())
+ .join(', ');
+ this.bankRegNo = profile?.licenseNumber ? `RBI Lic. No. ${profile.licenseNumber}` : '';
+ this.bankContact = [
+ profile?.contactEmail,
+ profile?.contactPhone
+ ]
+ .filter((p: any) => !!p && String(p).trim())
+ .join(' · ');
+ this.bankSealPrimary = (profile?.shortName || this.bankName).toUpperCase();
+ this.bankSealSecondary = profile?.bankType
+ ? profile.bankType.replace(/_/g, ' ').toUpperCase() + (/BANK$/i.test(profile.bankType) ? '' : ' BANK')
+ : '';
+
+ // Every certificate PDF this dialog can build (Download button below, or the list page's
+ // own download icon opened without previewing first) reads the same shared identity, so
+ // set it here once the real profile is in — never leave a PDF carrying the placeholder.
+ setBankIdentity(profile || {});
+ },
+ // A missing/incomplete profile must not stop the certificate from rendering.
+ error: () => {}
+ });
+ }
get isInterest(): boolean {
return this.data.kind === 'interest';
diff --git a/src/app/letter-mgmt/certificates/share-certificate/real-share-accounts-view/real-share-accounts-view.component.ts b/src/app/letter-mgmt/certificates/share-certificate/real-share-accounts-view/real-share-accounts-view.component.ts
index 655d42238..1df873a16 100644
--- a/src/app/letter-mgmt/certificates/share-certificate/real-share-accounts-view/real-share-accounts-view.component.ts
+++ b/src/app/letter-mgmt/certificates/share-certificate/real-share-accounts-view/real-share-accounts-view.component.ts
@@ -17,8 +17,9 @@ import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
import { SharesService } from '../../../../shares/shares.service';
import { ClientsService } from '../../../../clients/clients.service';
+import { OrganizationService } from '../../../../organization/organization.service';
import { ShareCertificateDialogComponent } from '../../../../shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component';
-import { downloadShareCertificatePdf } from '../../certificate-pdf';
+import { downloadShareCertificatePdf, setBankIdentity } from '../../certificate-pdf';
import { DscSignService } from '../../../dsc-sign-dialog/dsc-sign.service';
import type { ShareClass, ShareCertificateRecord } from '../share-certificate.component';
@@ -47,6 +48,7 @@ export class RealShareAccountsViewComponent implements OnInit {
private dialog = inject(MatDialog);
private sharesService = inject(SharesService);
private clientsService = inject(ClientsService);
+ private organizationService = inject(OrganizationService);
private dscSign = inject(DscSignService);
loading = true;
@@ -70,6 +72,14 @@ export class RealShareAccountsViewComponent implements OnInit {
ngOnInit(): void {
this.loadRealAccounts();
+
+ // The Download button below builds the PDF directly (no preview dialog in between), so this
+ // view must set the shared bank identity itself rather than relying on some other screen
+ // having opened first.
+ this.organizationService.getInstitutionProfile().subscribe({
+ next: (profile: any) => setBankIdentity(profile || {}),
+ error: () => {}
+ });
}
private loadRealAccounts(): void {
diff --git a/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.ts b/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.ts
index 47e65b44a..851899d5b 100644
--- a/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.ts
+++ b/src/app/letter-mgmt/certificates/share-certificate/share-certificate.component.ts
@@ -19,8 +19,9 @@ import {
CertificatePreviewDialogComponent,
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
-import { downloadShareCertificatePdf } from '../certificate-pdf';
+import { downloadShareCertificatePdf, setBankIdentity } from '../certificate-pdf';
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
+import { OrganizationService } from '../../../organization/organization.service';
import { RealShareAccountsViewComponent } from './real-share-accounts-view/real-share-accounts-view.component';
export type ShareClass = 'ORDINARY' | 'PREFERENCE';
@@ -62,6 +63,7 @@ export class ShareCertificateComponent implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
private dscSign = inject(DscSignService);
+ private organizationService = inject(OrganizationService);
/** Toggles between the static sample table and the live m_share_account view. */
viewMode: 'demo' | 'real' = 'demo';
@@ -80,6 +82,13 @@ export class ShareCertificateComponent implements OnInit {
ngOnInit(): void {
this.records = seedShareCertificates();
+
+ // Download here builds the PDF directly, without necessarily opening the preview dialog first,
+ // so this list must set the shared bank identity itself.
+ this.organizationService.getInstitutionProfile().subscribe({
+ next: (profile: any) => setBankIdentity(profile || {}),
+ error: () => {}
+ });
}
// ── Derived list ──
diff --git a/src/app/organization/institution-profile/institution-profile.component.html b/src/app/organization/institution-profile/institution-profile.component.html
index 4732fdd0d..a4d4f79a2 100644
--- a/src/app/organization/institution-profile/institution-profile.component.html
+++ b/src/app/organization/institution-profile/institution-profile.component.html
@@ -275,9 +275,12 @@
Holiday Calendar
- Financial Year / Closing Entries
+
+ @if (!disabledFeatures.closingEntries) {
+ Financial Year / Closing Entries
+ }
Business Date (Day Register)
diff --git a/src/app/organization/institution-profile/institution-profile.component.ts b/src/app/organization/institution-profile/institution-profile.component.ts
index 41e8b41e5..2d04f9480 100644
--- a/src/app/organization/institution-profile/institution-profile.component.ts
+++ b/src/app/organization/institution-profile/institution-profile.component.ts
@@ -12,6 +12,7 @@ import { catchError, of } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from '../organization.service';
import { SettingsService } from 'app/settings/settings.service';
+import { DISABLED_ACCOUNTING_FEATURES } from '../../accounting/disabled-features';
const BANK_TYPES = [
{ value: 'COOPERATIVE', label: 'Co-operative Bank' },
@@ -63,6 +64,8 @@ export class InstitutionProfileComponent implements OnInit {
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
+ /** Accounting features switched off for this installation — see accounting/disabled-features.ts. */
+ disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
bankTypes = BANK_TYPES;
ownershipTypes = OWNERSHIP_TYPES;
licenseStatuses = LICENSE_STATUSES;
diff --git a/src/app/platform-admin/menu-access/menu-access.component.ts b/src/app/platform-admin/menu-access/menu-access.component.ts
index e98bcfebd..c5607a687 100644
--- a/src/app/platform-admin/menu-access/menu-access.component.ts
+++ b/src/app/platform-admin/menu-access/menu-access.component.ts
@@ -104,7 +104,8 @@ export class PlatformMenuAccessComponent {
'X-Api-Key': this.apiKey,
// Any real tenant satisfies the platform's tenant-resolution filter; the target tenant for
// read/write is this header's own value, so it is set to whichever tenant is actually in play.
- 'Fineract-Platform-TenantId': tenantForFilter || this.selectedTenant || (this.tenants[0]?.identifier ?? 'default')
+ 'Fineract-Platform-TenantId':
+ tenantForFilter || this.selectedTenant || this.tenants[0]?.identifier || environment.fineractPlatformTenantId
};
}
@@ -126,7 +127,9 @@ export class PlatformMenuAccessComponent {
this.rememberKey();
this.loadingTenants = true;
try {
- const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), { headers: this.headers('default') });
+ const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), {
+ headers: this.headers(environment.fineractPlatformTenantId)
+ });
if (!res.ok) {
throw new Error(
res.status === 403 || res.status === 401 ? 'Invalid or inactive API key.' : `Server error (${res.status}).`
diff --git a/src/app/products/share-products/create-share-product/create-share-product.component.html b/src/app/products/share-products/create-share-product/create-share-product.component.html
index 89efc79c2..3d5ee4a18 100644
--- a/src/app/products/share-products/create-share-product/create-share-product.component.html
+++ b/src/app/products/share-products/create-share-product/create-share-product.component.html
@@ -110,8 +110,7 @@
[shareProductsTemplate]="shareProductsTemplate"
[accountingRuleData]="accountingRuleData"
[shareProduct]="shareProduct"
- [taskPermission]="'CREATE_SHAREPRODUCT'"
- (submitEvent)="submit()"
+ [showActions]="false"
>
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 91c9f9ec0..4bc274f94 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
@@ -250,11 +250,13 @@
-
+}
diff --git a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts
index 1c1fc2c11..c725721bb 100644
--- a/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts
+++ b/src/app/products/share-products/share-product-stepper/share-product-preview-step/share-product-preview-step.component.ts
@@ -52,6 +52,10 @@ export class ShareProductPreviewStepComponent {
@Input() accountingRuleData: any;
@Input() shareProduct: any;
@Input() taskPermission: string;
+ /** Set to false when the host page already renders its own Cancel/Submit bar for this step
+ * (the Create flow's shared bottom action bar) — Edit's stepper has no such bar of its own, so
+ * this defaults to true and keeps rendering the button there. */
+ @Input() showActions = true;
@Output() submitEvent = new EventEmitter();
marketPriceDisplayedColumns: string[] = [
diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html
index f614c533c..8d8edac1e 100644
--- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html
+++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.html
@@ -51,15 +51,6 @@
Could not load the review queue.
-
-
0" class="ra-demo">
- science
-
- Showing sample data — the AML engine returned no recurring alerts for this threshold and window.
- These rows are for demonstration only and are not reportable.
-
-
-
Customer
diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss
index 0f64353be..9d62ff9ed 100644
--- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss
+++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.scss
@@ -79,27 +79,6 @@
font-size: 13px;
}
-.ra-demo {
- display: flex;
- align-items: flex-start;
- gap: 8px;
- padding: 10px 12px;
- margin-bottom: 12px;
- border: 1px solid #fde68a;
- border-radius: 6px;
- background: #fffbeb;
- color: #92400e;
- font-size: 12.5px;
- line-height: 1.5;
-
- mat-icon {
- flex: 0 0 auto;
- font-size: 18px;
- width: 18px;
- height: 18px;
- }
-}
-
.ra-table {
width: 100%;
border-collapse: separate;
diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts
index e69fcd8d5..bf2198e8c 100644
--- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts
+++ b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.component.ts
@@ -25,7 +25,6 @@ import { AmlService } from '../../../aml/aml.service';
import { AlertService } from '../../../core/alert/alert.service';
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
import { AmlRecurringReviewDialogComponent } from './aml-recurring-review-dialog.component';
-import { amlRecurringAlertsDemoData } from './aml-recurring-alerts.demo-data';
/**
* Customers whose transactions keep tripping the AML engine, queued for an employee to review why.
@@ -95,8 +94,6 @@ export class AmlRecurringAlertsComponent implements OnInit {
loading = false;
loadFailed = false;
- /** Set when the rows on screen are the sample set rather than engine output. */
- usingDemoData = false;
/** Which customer's alert detail is expanded inline. */
expandedClientId: number | null = null;
@@ -111,20 +108,20 @@ export class AmlRecurringAlertsComponent implements OnInit {
this.amlService.getRecurringAlerts(this.minAlerts, this.windowDays, this.onlyNeedingReview).subscribe({
next: (data) => {
this.loading = false;
- const rows = data || [];
- this.usingDemoData = rows.length === 0;
- this.rows.data = this.mapRows(this.usingDemoData ? this.demoRows() : rows);
+ this.rows.data = this.mapRows(data || []);
},
error: () => {
this.loading = false;
this.loadFailed = true;
- this.usingDemoData = true;
- this.rows.data = this.mapRows(this.demoRows());
+ // An AML review queue shows what the engine found or nothing at all. Standing in sample
+ // customers when the call fails invents suspicious activity against names that are not
+ // the bank's, which is worse than an empty table saying the load failed.
+ this.rows.data = [];
}
});
}
- /** Parses the date fields the template renders. Shared by engine rows and the sample set. */
+ /** Parses the date fields the template renders. */
private mapRows(rows: any[]): any[] {
return rows.map((r: any) => ({
...r,
@@ -135,36 +132,6 @@ export class AmlRecurringAlertsComponent implements OnInit {
}));
}
- /**
- * The sample set narrowed by the on-screen controls, so the threshold and window selectors behave
- * the way they would against the engine instead of returning the same three rows regardless.
- */
- private demoRows(): any[] {
- const cutoff = Date.now() - this.windowDays * 24 * 60 * 60 * 1000;
- return amlRecurringAlertsDemoData()
- .map((row: any) => {
- const alerts = (row.alerts || []).filter((a: any) => new Date(a.raisedOn).getTime() >= cutoff);
- const ruleCodes = Array.from(new Set(alerts.map((a: any) => a.ruleCode)));
- return {
- ...row,
- alerts,
- alertCount: alerts.length,
- openCount: alerts.filter((a: any) => a.status === 'OPEN').length,
- escalatedCount: alerts.filter((a: any) => a.status === 'ESCALATED').length,
- // Severity sits on the alert server-side; the sample rows carry a row total, so it is
- // capped at what survived the window rather than recomputed.
- highRiskCount: Math.min(row.highRiskCount, alerts.length),
- distinctRules: ruleCodes.length,
- ruleCodes,
- totalAmount: alerts.reduce((sum: number, a: any) => sum + (Number(a.amount) || 0), 0),
- firstAlertOn: alerts.length ? alerts[0].raisedOn : null,
- lastAlertOn: alerts.length ? alerts[alerts.length - 1].raisedOn : null
- };
- })
- .filter((row: any) => row.alertCount >= this.minAlerts)
- .filter((row: any) => !this.onlyNeedingReview || row.needsReview);
- }
-
toggleExpanded(row: any): void {
this.expandedClientId = this.expandedClientId === row.clientId ? null : row.clientId;
}
diff --git a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts b/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts
deleted file mode 100644
index ffab67a8e..000000000
--- a/src/app/rbi-circulars/aml-rules/aml-recurring-alerts/aml-recurring-alerts.demo-data.ts
+++ /dev/null
@@ -1,248 +0,0 @@
-/**
- * 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/.
- */
-
-/**
- * Sample recurring-alert rows for the AML review queue, used only when the engine returns nothing
- * for the selected window so the screen can be demonstrated and reviewed.
- *
- * Shaped exactly like the `/aml/recurring-alerts` response — dates as ISO strings, alerts nested —
- * so the rows travel through the component's normal mapping rather than a second code path.
- *
- * Dates are generated relative to today rather than hard-coded, otherwise every row would fall
- * outside the "last 90 days" window as soon as the file aged.
- */
-
-/** ISO timestamp `days` before now, at the given hour and minute. */
-function daysAgo(days: number, hour = 11, minute = 20): string {
- const d = new Date();
- d.setDate(d.getDate() - days);
- d.setHours(hour, minute, 0, 0);
- return d.toISOString();
-}
-
-/**
- * Three customers covering the three review states the table renders: never reviewed, reviewed but
- * flagged again since, and reviewed with an outcome recorded.
- */
-export function amlRecurringAlertsDemoData(): any[] {
- return [
- // Never reviewed — the row the queue exists to surface.
- {
- clientId: 10078,
- clientDisplayName: 'Ravi Kumar Sharma',
- accountNo: '000000010078',
- clientRiskCategory: 3,
- alertCount: 5,
- highRiskCount: 3,
- openCount: 4,
- escalatedCount: 1,
- distinctRules: 3,
- ruleCodes: [
- 'CASH_TXN_10L',
- 'STRUCTURING',
- 'RAPID_MOVEMENT'
- ],
- totalAmount: 4785000,
- firstAlertOn: daysAgo(62, 10, 5),
- lastAlertOn: daysAgo(3, 15, 42),
- needsReview: true,
- lastReviewedOn: null,
- alertCountAtLastReview: null,
- lastReviewOutcome: null,
- lastReviewedByUsername: null,
- lastReviewNotes: null,
- alerts: [
- {
- alertId: 90121,
- raisedOn: daysAgo(62, 10, 5),
- transactionType: 'Savings Deposit',
- transactionId: 552301,
- ruleCode: 'CASH_TXN_10L',
- ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
- amount: 1200000,
- status: 'OPEN',
- reviewNotes: null
- },
- {
- alertId: 90144,
- raisedOn: daysAgo(48, 12, 18),
- transactionType: 'Savings Deposit',
- transactionId: 553980,
- ruleCode: 'STRUCTURING',
- ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours',
- amount: 890000,
- status: 'OPEN',
- reviewNotes: null
- },
- {
- alertId: 90190,
- raisedOn: daysAgo(29, 9, 55),
- transactionType: 'Savings Withdrawal',
- transactionId: 556114,
- ruleCode: 'RAPID_MOVEMENT',
- ruleDescription: 'Funds withdrawn within 24 hours of a large credit',
- amount: 1150000,
- status: 'ESCALATED',
- reviewNotes: 'Escalated to Principal Officer — source of funds not established.'
- },
- {
- alertId: 90233,
- raisedOn: daysAgo(14, 16, 30),
- transactionType: 'Savings Deposit',
- transactionId: 558702,
- ruleCode: 'CASH_TXN_10L',
- ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
- amount: 1045000,
- status: 'OPEN',
- reviewNotes: null
- },
- {
- alertId: 90275,
- raisedOn: daysAgo(3, 15, 42),
- transactionType: 'Savings Withdrawal',
- transactionId: 560488,
- ruleCode: 'STRUCTURING',
- ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours',
- amount: 500000,
- status: 'OPEN',
- reviewNotes: null
- }
- ]
- },
-
- // Reviewed at 2 alerts, flagged twice since — returns to the queue as "New activity".
- {
- clientId: 10079,
- clientDisplayName: 'Priya Patel',
- accountNo: '000000010079',
- clientRiskCategory: 2,
- alertCount: 4,
- highRiskCount: 1,
- openCount: 2,
- escalatedCount: 0,
- distinctRules: 2,
- ruleCodes: [
- 'HIGH_VALUE_TRANSFER',
- 'ROUND_TRIPPING'
- ],
- totalAmount: 2130000,
- firstAlertOn: daysAgo(71, 14, 10),
- lastAlertOn: daysAgo(6, 11, 5),
- needsReview: true,
- lastReviewedOn: daysAgo(34, 17, 0),
- alertCountAtLastReview: 2,
- lastReviewOutcome: 'MONITOR',
- lastReviewedByUsername: 'anjali.mehta',
- lastReviewNotes: 'Textile trading receipts, invoices seen. Keep under watch for one more quarter.',
- alerts: [
- {
- alertId: 90102,
- raisedOn: daysAgo(71, 14, 10),
- transactionType: 'Account Transfer',
- transactionId: 551002,
- ruleCode: 'HIGH_VALUE_TRANSFER',
- ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account',
- amount: 620000,
- status: 'WHITELISTED',
- reviewNotes: 'Supplier payment, invoice verified.'
- },
- {
- alertId: 90158,
- raisedOn: daysAgo(41, 10, 45),
- transactionType: 'Account Transfer',
- transactionId: 554330,
- ruleCode: 'ROUND_TRIPPING',
- ruleDescription: 'Funds returned to the originating account within seven days',
- amount: 450000,
- status: 'WHITELISTED',
- reviewNotes: 'Advance refunded after order cancellation.'
- },
- {
- alertId: 90241,
- raisedOn: daysAgo(19, 13, 25),
- transactionType: 'Account Transfer',
- transactionId: 559011,
- ruleCode: 'HIGH_VALUE_TRANSFER',
- ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account',
- amount: 540000,
- status: 'OPEN',
- reviewNotes: null
- },
- {
- alertId: 90268,
- raisedOn: daysAgo(6, 11, 5),
- transactionType: 'Account Transfer',
- transactionId: 560190,
- ruleCode: 'ROUND_TRIPPING',
- ruleDescription: 'Funds returned to the originating account within seven days',
- amount: 520000,
- status: 'OPEN',
- reviewNotes: null
- }
- ]
- },
-
- // Reviewed with an outcome and no activity since — shows the settled state.
- {
- clientId: 10080,
- clientDisplayName: 'Arjun Deshmukh',
- accountNo: '000000010080',
- clientRiskCategory: 1,
- alertCount: 3,
- highRiskCount: 0,
- openCount: 0,
- escalatedCount: 0,
- distinctRules: 1,
- ruleCodes: ['CASH_TXN_10L'],
- totalAmount: 3420000,
- firstAlertOn: daysAgo(80, 9, 30),
- lastAlertOn: daysAgo(23, 12, 15),
- needsReview: false,
- lastReviewedOn: daysAgo(9, 15, 20),
- alertCountAtLastReview: 3,
- lastReviewOutcome: 'CLEARED',
- lastReviewedByUsername: 'vikram.singh',
- lastReviewNotes: 'Sugarcane crushing-season proceeds. Mill settlement statements filed with the branch.',
- alerts: [
- {
- alertId: 90088,
- raisedOn: daysAgo(80, 9, 30),
- transactionType: 'Savings Deposit',
- transactionId: 550210,
- ruleCode: 'CASH_TXN_10L',
- ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
- amount: 1180000,
- status: 'WHITELISTED',
- reviewNotes: 'Mill settlement for the 2025-26 crushing season.'
- },
- {
- alertId: 90165,
- raisedOn: daysAgo(52, 11, 0),
- transactionType: 'Savings Deposit',
- transactionId: 554901,
- ruleCode: 'CASH_TXN_10L',
- ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
- amount: 1090000,
- status: 'WHITELISTED',
- reviewNotes: 'Second instalment, statement on record.'
- },
- {
- alertId: 90212,
- raisedOn: daysAgo(23, 12, 15),
- transactionType: 'Savings Deposit',
- transactionId: 557640,
- ruleCode: 'CASH_TXN_10L',
- ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
- amount: 1150000,
- status: 'WHITELISTED',
- reviewNotes: 'Final instalment for the season.'
- }
- ]
- }
- ];
-}
diff --git a/src/app/risk-review/risk-review-data.ts b/src/app/risk-review/risk-review-data.ts
index 3e054ceda..633de824d 100644
--- a/src/app/risk-review/risk-review-data.ts
+++ b/src/app/risk-review/risk-review-data.ts
@@ -7,6 +7,7 @@
*/
import { GL_BRANCHES, GL_PERIOD } from '../gl-reports/gl-reports-data';
+import { toIsoDate } from '../core/utils/api-date';
/**
* Data layer for the Risk Review Report.
@@ -390,17 +391,23 @@ export interface LiveAmlAlert {
ruleDescription: string;
amount: number;
currencyCode: string;
- raisedOn: string;
+ /** Fineract sends LocalDateTime as [y,m,d,h,mi,s], not a string. */
+ raisedOn: number[] | string;
status: string;
reviewedByUsername: string | null;
- reviewedOn: string | null;
+ reviewedOn: number[] | string | null;
reviewNotes: string | null;
disposition: string | null;
}
-/** Trims a LocalDateTime such as 2026-07-14T11:04:22 down to its date part. */
-function isoDayOf(value: string | null | undefined): string {
- return (value || '').slice(0, 10);
+/**
+ * Fineract serialises LocalDateTime as an array — raisedOn arrives as
+ * [2026,9,11,0,0,0], not a string. Slicing that gave back an array rather than
+ * a date, so every live row carried an unusable alertDate and the period filter
+ * discarded it: the report read empty while the engine held alerts.
+ */
+function isoDayOf(value: unknown): string {
+ return toIsoDate(value);
}
/**
diff --git a/src/app/risk-review/risk-review-report.component.html b/src/app/risk-review/risk-review-report.component.html
index 40d308e22..503cb0dad 100644
--- a/src/app/risk-review/risk-review-report.component.html
+++ b/src/app/risk-review/risk-review-report.component.html
@@ -150,33 +150,62 @@
} @else {
-
+
+
{{ summary.customers }}
Customers for review
{{ summary.byRisk.LOW }} Low · {{ summary.byRisk.MEDIUM }} Medium · {{ summary.byRisk.HIGH }} High
-
-
+
+
{{ summary.alerts }}
AML alerts triggered
on {{ summary.txns }} transactions · {{ summary.openAlerts }} awaiting a decision
0">
+
+ 0"
+ [class.rr-kpi--dim]="summary.escalatedCustomers === 0"
+ [class.rr-kpi--on]="status === 'ESCALATED_STR'"
+ (click)="focusEscalated()"
+ [title]="
+ summary.escalatedCustomers === 0
+ ? 'No case has gone to the Principal Officer in this period'
+ : 'Show only the cases with the Principal Officer'
+ "
+ >
{{ summary.escalatedCustomers }}
Escalated to STR
₹{{ summary.totalAmount | number: '1.0-0' }} under review
-
+
@@ -220,7 +249,7 @@
row does not have to repeat it in a Move To and a Position column. -->
@for (g of customerGroups; track g.key) {
diff --git a/src/app/risk-review/risk-review-report.component.scss b/src/app/risk-review/risk-review-report.component.scss
index 117eae800..c4f9b6066 100644
--- a/src/app/risk-review/risk-review-report.component.scss
+++ b/src/app/risk-review/risk-review-report.component.scss
@@ -286,12 +286,61 @@
margin-bottom: 14px;
}
+/* The verdict, on the row. Colour carries the same meaning as the section it sits under, so a
+ reviewer scanning the middle of a long table does not have to look back up to the heading. */
+.rr-move {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ font-size: 11.5px;
+ font-weight: 700;
+ white-space: nowrap;
+
+ &--up {
+ background: #fef3c7;
+ color: #92400e;
+ }
+
+ &--down {
+ background: #dbeafe;
+ color: #1e40af;
+ }
+
+ &--same {
+ background: #f1f5f9;
+ color: var(--r-g500);
+ }
+}
+
+/* A button, so the tile is reachable by keyboard and reads as the control it is. The type
+ defaults that come with that — centred text, the button font, the UA border — are undone
+ here so it still looks like the panel it replaced. */
.rr-kpi {
+ display: block;
+ width: 100%;
background: #fff;
border: 1px solid var(--r-g200);
border-left: 4px solid var(--r-navy);
border-radius: 11px;
padding: 12px 16px;
+ text-align: left;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+ transition:
+ box-shadow 0.15s ease,
+ border-color 0.15s ease;
+
+ &:hover {
+ box-shadow: 0 1px 6px rgb(15 23 42 / 12%);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--r-blue);
+ outline-offset: 2px;
+ }
&--blue {
border-left-color: var(--r-blue);
@@ -307,6 +356,19 @@
&--dim {
border-left-color: var(--r-g300);
+
+ /* Nothing to narrow to, so the tile says so rather than accepting a click that does nothing. */
+ cursor: default;
+
+ &:hover {
+ box-shadow: none;
+ }
+ }
+
+ /* The filter this tile applies is currently on. */
+ &--on {
+ border-color: var(--r-navy);
+ box-shadow: inset 0 0 0 1px var(--r-navy);
}
}
@@ -502,9 +564,12 @@
}
td {
- padding: 8px 10px;
+ padding: 11px 10px;
border-bottom: 1px solid var(--r-g100);
- vertical-align: top;
+
+ /* Middle rather than top: one cell in the row carries a two-line reason, and top alignment
+ left every other value floating against the row above it. */
+ vertical-align: middle;
}
.num {
@@ -538,6 +603,9 @@
td {
padding: 12px 16px 16px;
+
+ /* The expanded block is a panel, not a value, so it starts at the top of its cell. */
+ vertical-align: top;
}
}
@@ -789,6 +857,20 @@
}
/* What the report says should happen to the rating */
+
+/* The verdict and the sentence behind it. Wide enough that the reason wraps to two readable
+ lines instead of a narrow ragged column. */
+.rr-move-cell {
+ min-width: 250px;
+ max-width: 330px;
+}
+
+/* Just the button, so the column is only as wide as the control it holds. */
+.rr-action-cell {
+ width: 1%;
+ white-space: nowrap;
+}
+
.rr-verdict-cell {
min-width: 210px;
max-width: 300px;
@@ -817,9 +899,10 @@
}
.rr-verdict-why {
- font-size: 10.5px;
+ font-size: 11px;
+ line-height: 1.45;
color: var(--r-g500);
- margin-top: 2px;
+ margin-top: 4px;
white-space: normal;
}
diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts
index bd59f7bd6..efa8d1880 100644
--- a/src/app/risk-review/risk-review-report.component.ts
+++ b/src/app/risk-review/risk-review-report.component.ts
@@ -224,21 +224,40 @@ export class RiskReviewReportComponent implements OnInit {
return;
}
+ // The alert carries the client but not their account number or branch, so both columns read
+ // as an em dash on every live row. Both sit on the client record the KYC call already needs,
+ // so they are fetched alongside it rather than left blank on a compliance report.
forkJoin(
clientIds.map((id) =>
- this.clients.getClientKyc(String(id), true).pipe(
- map((kyc: any) => ({ id, risk: riskFromCategory(kyc?.riskCategory) })),
- catchError(() => of({ id, risk: null as RiskLevel | null }))
+ forkJoin({
+ kyc: this.clients.getClientKyc(String(id), true).pipe(catchError(() => of(null))),
+ client: this.clients.getClientData(String(id), true).pipe(catchError(() => of(null)))
+ }).pipe(
+ map((r: any) => ({
+ id,
+ risk: riskFromCategory(r.kyc?.riskCategory),
+ accountNo: r.client?.accountNo || '',
+ branch: r.client?.officeName || ''
+ })),
+ catchError(() => of({ id, risk: null as RiskLevel | null, accountNo: '', branch: '' }))
)
)
).subscribe((results) => {
const byClient = new Map(
results.map((r) => [
r.id,
- r.risk
+ r
])
);
- this.liveAlerts = rows.map((r) => ({ ...r, currentRisk: byClient.get(r.clientId) ?? null }));
+ this.liveAlerts = rows.map((r) => {
+ const info = byClient.get(r.clientId);
+ return {
+ ...r,
+ currentRisk: info?.risk ?? null,
+ accountNo: r.accountNo || info?.accountNo || '',
+ branchCode: r.branchCode || info?.branch || ''
+ };
+ });
this.compose();
this.loading = false;
});
@@ -389,8 +408,62 @@ export class RiskReviewReportComponent implements OnInit {
return groups.filter((g) => g.rows.length > 0);
}
- /** Collapsed sections, by group key. 'No change needed' starts closed — nothing to act on. */
- collapsedGroups: Record = { same: true };
+ /**
+ * Collapsed sections, by group key. Every group starts open.
+ *
+ * 'No change needed' used to start closed on the reasoning that there was nothing to act on. On a
+ * report that often holds one group, that meant opening the page to a heading and a count and no
+ * rows — the customer was there, but reading the report took a click to discover it. The toggle
+ * stays for folding a long section away.
+ */
+ collapsedGroups: Record = {};
+
+ /**
+ * The summary tiles read as a question each — "which customers?", "which alerts still need a
+ * decision?" — so each one narrows the table to the rows it counted. They were plain text
+ * before, which invited a click that did nothing and left the reviewer to rebuild the same
+ * filter by hand from the dropdowns.
+ *
+ * Clicking an active tile clears it again, so a tile is never a one-way trip into a filter the
+ * reviewer then has to hunt for.
+ */
+ focusAllCustomers(): void {
+ this.currentRisk = '';
+ this.direction = '';
+ this.openOnly = false;
+ this.expanded = null;
+ this.collapsedGroups = {};
+ }
+
+ focusOpenAlerts(): void {
+ this.openOnly = !this.openOnly;
+ this.direction = '';
+ this.expanded = null;
+ this.collapsedGroups = {};
+ }
+
+ /** Only the customers whose rating the review says should move, either way. */
+ focusRatingsToMove(): void {
+ // Nothing to narrow to when no rating needs moving; leaving the filter alone keeps the
+ // table showing what is actually there rather than emptying it.
+ if (this.summary.upgradeCandidates + this.summary.downgradeCandidates === 0) {
+ return;
+ }
+ this.direction = this.direction === 'up' ? 'down' : this.direction === 'down' ? '' : 'up';
+ this.openOnly = false;
+ this.expanded = null;
+ this.collapsedGroups = {};
+ }
+
+ focusEscalated(): void {
+ if (this.summary.escalatedCustomers === 0) {
+ return;
+ }
+ this.status = this.status === 'ESCALATED_STR' ? '' : 'ESCALATED_STR';
+ this.openOnly = false;
+ this.expanded = null;
+ this.collapsedGroups = {};
+ }
toggleGroup(key: string): void {
this.collapsedGroups[key] = !this.collapsedGroups[key];
@@ -513,6 +586,47 @@ export class RiskReviewReportComponent implements OnInit {
* An escalated case is worded as a review rather than a move: the rating is held
* pending the Principal Officer's conclusion, so there is nothing to apply yet.
*/
+ /**
+ * The verdict in the words a reviewer needs: which way the rating moves, and to what.
+ *
+ * An escalated case is deliberately not given a direction — the rating is held where it is
+ * until the Principal Officer reports back, so showing "raise to High" there would invite a
+ * change nobody is authorised to make yet.
+ */
+ directionText(c: RiskCustomerRow): string {
+ if (c.escalated) {
+ return 'Held — with Principal Officer';
+ }
+ if (c.direction === 'up') {
+ return `Raise to ${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';
+ }
+
+ directionIcon(c: RiskCustomerRow): string {
+ if (c.escalated) {
+ 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';
+ }
+
+ /**
+ * A live row carries the office name straight off the client record, not a GL branch code, so
+ * it is already the label to print. Only a coded row needs the lookup.
+ */
+ branchLabelOf(branch: string): string {
+ if (!branch) {
+ return '—';
+ }
+ const named = glBranchName(branch);
+ return named && named !== branch ? named : branch;
+ }
+
kycActionText(c: RiskCustomerRow): string {
if (c.escalated) {
return 'Open KYC';
@@ -689,7 +803,7 @@ export class RiskReviewReportComponent implements OnInit {
String(c.clientId),
c.customerName,
c.accountNo || '—',
- c.branchCode ? glBranchName(c.branchCode) : '—',
+ this.branchLabelOf(c.branchCode),
this.riskText(c.currentRisk),
c.alertCount,
c.ruleLabels.join('; '),
@@ -749,7 +863,7 @@ export class RiskReviewReportComponent implements OnInit {
String(a.clientId),
a.customerName,
a.accountNo || '—',
- a.branchCode ? glBranchName(a.branchCode) : '—',
+ this.branchLabelOf(a.branchCode),
this.riskText(a.currentRisk),
this.txnDetail(a),
a.rule.label,
@@ -815,7 +929,7 @@ export class RiskReviewReportComponent implements OnInit {
String(t.clientId),
t.customerName,
t.accountNo || '—',
- t.branchCode ? glBranchName(t.branchCode) : '—',
+ this.branchLabelOf(t.branchCode),
this.riskText(t.currentRisk),
channelLabel(t.channel),
t.drCr,
diff --git a/src/app/risk-review/risk-review.compute.ts b/src/app/risk-review/risk-review.compute.ts
index ce0dd9000..67499a2bc 100644
--- a/src/app/risk-review/risk-review.compute.ts
+++ b/src/app/risk-review/risk-review.compute.ts
@@ -218,7 +218,14 @@ function recommendRating(
};
}
- return { risk, reason: '' };
+ // 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 {
+ risk,
+ reason: currentRisk
+ ? `${sizes}. Consistent with ${RISK_LABEL[risk]} — the rating on file stands.`
+ : `${sizes}. Activity points at ${RISK_LABEL[risk]}; no rating on record to compare against.`
+ };
}
/** Applies every filter and joins each alert to its rule. */
diff --git a/src/app/savings/create-savings-account/create-savings-account.component.html b/src/app/savings/create-savings-account/create-savings-account.component.html
index dfbf67e7c..f90406dab 100644
--- a/src/app/savings/create-savings-account/create-savings-account.component.html
+++ b/src/app/savings/create-savings-account/create-savings-account.component.html
@@ -91,16 +91,10 @@
[savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []"
[submitting]="submitting"
(submitEvent)="submit()"
+ (previousEvent)="prev()"
>
}
-
-
}
diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss
index cffbf47eb..8c772a802 100644
--- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss
+++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.scss
@@ -13,17 +13,21 @@
padding: 0.5rem 0;
}
-/* Submit + Cancel row at the bottom of the preview. Previous lives in the
- parent wizard's tab action bar. */
+/* Previous, Cancel & Submit row at the bottom of the preview. */
.preview-submit-row {
display: flex;
- justify-content: flex-end;
+ justify-content: space-between;
align-items: center;
- gap: 12px;
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid #f1f5f9;
+ &__right {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
button {
height: 42px !important;
border-radius: 10px !important;
diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts
index 880e51d90..c86dc9198 100644
--- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts
+++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts
@@ -116,6 +116,9 @@ export class SavingsAccountPreviewStepComponent implements OnChanges {
/** Form submission event */
@Output() submitEvent = new EventEmitter();
+ /** Goes back a tab in the parent wizard — kept here so Previous renders in the same row as
+ * Cancel/Submit instead of a separate action bar underneath. */
+ @Output() previousEvent = new EventEmitter();
ngOnChanges(): void {
if (this.activeClientMembers?.length > 0) {
diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html
index 130f27404..78a310134 100644
--- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html
+++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.html
@@ -21,8 +21,10 @@
-
Nexora Co-operative Bank Ltd.
-
Mumbai, Maharashtra, India
+
{{ bankName }}
+ @if (bankLocation) {
+
{{ bankLocation }}
+ }
SHARE CERTIFICATE
Issued under the Common Seal of the Bank
@@ -51,8 +53,8 @@
{{
sharesAccountData.currentMarketPrice | currency: sharesAccountData.currency.code : 'symbol-narrow'
}}
- each, fully paid up, in Nexora Co-operative Bank Ltd., subject to
- the memorandum, articles of association, and bye-laws of the bank.
+ each, fully paid up, in {{ bankName }}, subject to the memorandum, articles of association, and bye-laws of the bank.
@@ -75,7 +77,7 @@
Branch
-
{{ clientData?.officeName || 'Mumbai Main Branch' }}
+
{{ clientData?.officeName || 'Head Office' }}
Member Since
{{
diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss
index 97e7362ac..840774130 100644
--- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss
+++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.scss
@@ -140,6 +140,8 @@
}
.meta-value {
+ font-family: Arial, Helvetica, sans-serif;
+ font-variant-numeric: tabular-nums;
font-size: 15px;
font-weight: bold;
color: #322f2b;
@@ -226,12 +228,19 @@
.lbl-col {
background-color: #f7f5ee;
+ font-family: Georgia, 'Times New Roman', Times, serif;
font-weight: bold;
color: #7b6f63;
width: 25%;
}
+ // Every figure a member actually needs to read at a glance — share counts, amounts, dates, the
+ // resolution number — sits in this column. Georgia's numerals kept blurring into each other at
+ // this size, so values get a plain sans-serif with tabular figures instead; the label column
+ // keeps the certificate's own serif since it's static text, never a number to misread.
.val-col {
+ font-family: Arial, Helvetica, sans-serif;
+ font-variant-numeric: tabular-nums;
color: #322f2b;
width: 25%;
}
@@ -242,6 +251,7 @@
}
.lbl-col-highlight {
+ font-family: Georgia, 'Times New Roman', Times, serif;
font-weight: bold;
color: #0b3c5d;
font-size: 14px;
@@ -251,6 +261,8 @@
}
.val-col-highlight {
+ font-family: Arial, Helvetica, sans-serif;
+ font-variant-numeric: tabular-nums;
font-size: 16px;
font-weight: bold;
color: #b8860b; /* Dark Goldenrod */
@@ -270,6 +282,7 @@
padding: 8px 12px;
border: 1px solid #ede8db;
background-color: #f7f5ee;
+ font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 11px;
font-weight: bold;
color: #7b6f63;
@@ -277,9 +290,14 @@
letter-spacing: 0.6px;
}
+ // Same reasoning as .val-col above: every cell here is a figure someone has to check against the
+ // register, so it reads in a plain sans-serif with tabular numerals rather than the certificate's
+ // decorative serif.
td {
padding: 9px 12px;
border: 1px solid #ede8db;
+ font-family: Arial, Helvetica, sans-serif;
+ font-variant-numeric: tabular-nums;
font-size: 13px;
color: #322f2b;
}
diff --git a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts
index 2b4c517b9..61605cc0a 100644
--- a/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts
+++ b/src/app/shares/shares-account-view/share-certificate-dialog/share-certificate-dialog.component.ts
@@ -16,6 +16,7 @@ import {
MatDialogClose
} from '@angular/material/dialog';
import { ClientsService } from 'app/clients/clients.service';
+import { OrganizationService } from 'app/organization/organization.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
@@ -44,6 +45,7 @@ export class ShareCertificateDialogComponent implements OnInit {
dialogRef = inject>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
clientsService = inject(ClientsService);
+ private organizationService = inject(OrganizationService);
sharesAccountData: any;
clientId: string;
@@ -53,6 +55,11 @@ export class ShareCertificateDialogComponent implements OnInit {
panNumber: string = 'Not Available';
memberId: string = '';
+ /** The issuing bank's own name/location, as set on the Institution Profile — never a fixed name,
+ * since this certificate template is shared by every tenant this app runs for. */
+ bankName: string = 'Bank';
+ bankLocation: string = '';
+
certificateNo: string = '';
folioNo: string = '';
shareNumbersRange: string = '';
@@ -155,6 +162,24 @@ export class ShareCertificateDialogComponent implements OnInit {
},
error: () => {}
});
+
+ // Fetch the issuing bank's own name/location from the Institution Profile, set once under
+ // Admin > Organization for whichever bank this instance runs for — never a fixed name baked
+ // into the certificate itself.
+ this.organizationService.getInstitutionProfile().subscribe({
+ next: (profile: any) => {
+ this.bankName = profile?.statementHeaderName || profile?.bankName || this.bankName;
+ this.bankLocation = [
+ profile?.city,
+ profile?.state,
+ profile?.country
+ ]
+ .filter((p) => !!p && String(p).trim())
+ .join(', ');
+ },
+ // A missing/incomplete profile must not stop the certificate from rendering.
+ error: () => {}
+ });
}
/**
diff --git a/src/app/shares/shares-account-view/shares-account-view.component.ts b/src/app/shares/shares-account-view/shares-account-view.component.ts
index e8318cff0..84103260f 100644
--- a/src/app/shares/shares-account-view/shares-account-view.component.ts
+++ b/src/app/shares/shares-account-view/shares-account-view.component.ts
@@ -149,6 +149,13 @@ export class SharesAccountViewComponent implements OnInit {
// capital that was never issued.
if (!this.isSettled) {
this.buttonConfig.removeOption('Share Certificate');
+ } else {
+ // "Apply Collection" settles the account's ORIGINAL application funding -- a one-time
+ // step. Once collectionApplied is true there is nothing left to settle this way; any
+ // further share purchases on this account go through "Apply Additional Shares" instead,
+ // which raises and settles its own tranche. Leaving this button up after settlement invited
+ // reopening the dialog against an account with no unapplied receipt to find.
+ this.buttonConfig.removeButton('Apply Collection');
}
}
}
diff --git a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts
index ddfc1d72a..f0f82b719 100644
--- a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts
+++ b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.ts
@@ -240,45 +240,83 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
this.approveBatch(request);
return;
}
- if (
- !confirm(
- `Approve DD issuance for DD No. ${request.ddNumber} (₹${request.faceValue})? This will debit the account (or clear the cheque) and issue the draft.`
- )
- ) {
- return;
+ this.dialog
+ .open(ApproveIssueRequestDialogComponent, {
+ width: '440px',
+ maxWidth: '95vw',
+ data: {
+ isBatch: false,
+ ddNumber: request.ddNumber,
+ beneficiaryName: request.beneficiaryName,
+ clientName: request.clientName,
+ totalFaceValue: Number(request.faceValue) || 0,
+ // Named for how this particular draft is funded, rather than offering the approver every
+ // possibility and leaving them to work out which one applies.
+ effect: this.effectTextFor(request)
+ }
+ })
+ .afterClosed()
+ .subscribe((confirmed) => {
+ if (!confirmed) {
+ return;
+ }
+ this.sponsorDDService.approveIssueRequest(request.id).subscribe({
+ next: () => {
+ this.snackBar.open('DD issuance approved and posted successfully', 'Close', { duration: 3000 });
+ this.loadRequests();
+ },
+ error: (err) => {
+ this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
+ }
+ });
+ });
+ }
+
+ /** What approving this request actually does, in the words of its own payment method. */
+ private effectTextFor(request: any, count = 1): string {
+ const drafts = count > 1 ? `${count} drafts` : 'the draft';
+ const once = count > 1 ? ' once for the combined total' : '';
+ switch ((request.paymentMethod || '').toUpperCase()) {
+ case 'ACCOUNT_DEBIT':
+ return `The customer's account is debited${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`;
+ case 'CHEQUE':
+ return `The cheque is marked cleared${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`;
+ default:
+ return `The cash collected is posted${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`;
}
- this.sponsorDDService.approveIssueRequest(request.id).subscribe({
- next: () => {
- this.snackBar.open('DD issuance approved and posted successfully', 'Close', { duration: 3000 });
- this.loadRequests();
- },
- error: (err) => {
- this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
- }
- });
}
private approveBatch(request: any): void {
const siblings = this.batchSiblings(request);
- const lines = siblings.map((r) => ` • ${r.beneficiaryName} — ₹${r.faceValue}`).join('\n');
const total = siblings.reduce((sum, r) => sum + (Number(r.faceValue) || 0), 0);
- if (
- !confirm(
- `Approve this DD slip - ${siblings.length} drafts, combined face value ₹${total}?\n${lines}\n\n` +
- 'This debits the account (or clears the cheque) ONCE for the combined total and issues every draft in the slip.'
- )
- ) {
- return;
- }
- this.sponsorDDService.approveIssueRequestBatch(request.batchId).subscribe({
- next: () => {
- this.snackBar.open('DD slip approved and posted successfully', 'Close', { duration: 3000 });
- this.loadRequests();
- },
- error: (err) => {
- this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
- }
- });
+
+ this.dialog
+ .open(ApproveIssueRequestDialogComponent, {
+ width: '460px',
+ maxWidth: '95vw',
+ data: {
+ isBatch: true,
+ clientName: request.clientName,
+ totalFaceValue: total,
+ lines: siblings.map((r) => ({ beneficiaryName: r.beneficiaryName, faceValue: Number(r.faceValue) || 0 })),
+ effect: this.effectTextFor(request, siblings.length)
+ }
+ })
+ .afterClosed()
+ .subscribe((confirmed) => {
+ if (!confirmed) {
+ return;
+ }
+ this.sponsorDDService.approveIssueRequestBatch(request.batchId).subscribe({
+ next: () => {
+ this.snackBar.open('DD slip approved and posted successfully', 'Close', { duration: 3000 });
+ this.loadRequests();
+ },
+ error: (err) => {
+ this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
+ }
+ });
+ });
}
openRejectDialog(request: any): void {
@@ -302,6 +340,123 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
}
}
+/**
+ * What the approver is about to commit, laid out rather than run together in a sentence.
+ *
+ * This was a browser `confirm()`, which the browser renders with its own "localhost:4200 says"
+ * chrome and no formatting — so the one figure that matters, the amount leaving the customer's
+ * account, sat mid-paragraph beside a parenthetical about cheques that does not apply to most
+ * drafts. An approval is the point of no return on someone else's money; it should read like one.
+ */
+@Component({
+ selector: 'mifosx-approve-issue-request-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ MatDialogModule,
+ MatButtonModule
+ ],
+ template: `
+
diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.ts b/src/app/sponsor-dd/dd-list/dd-list.component.ts
index a8863798f..d9df2dc0d 100644
--- a/src/app/sponsor-dd/dd-list/dd-list.component.ts
+++ b/src/app/sponsor-dd/dd-list/dd-list.component.ts
@@ -142,6 +142,12 @@ import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.
background-color: #fef3c7;
color: #b45309;
}
+ /* Spoiled stock. Grey rather than red: nothing went wrong with the customer's draft, only
+ with the paper it was printed on, and the draft itself is alive on another leaflet. */
+ .status-badge.revoked {
+ background-color: #e2e8f0;
+ color: #475569;
+ }
.action-btn-group {
display: flex;
gap: 8px;
@@ -212,7 +218,10 @@ export class DdListComponent implements OnInit, AfterViewInit {
'ISSUED',
'CANCELLED',
'STOPPED',
- 'CLEARED'
+ 'CLEARED',
+ // Spoiled at the printer. Filterable on its own because it is the count that explains a pad
+ // whose issued drafts do not add up to the numbers consumed.
+ 'REVOKED'
];
selectedStatus = 'ALL';
searchText = '';
@@ -444,6 +453,23 @@ export class DdListComponent implements OnInit, AfterViewInit {
}
});
}
+
+ /** The leaflet was ruined at the printer and its draft has to move onto a fresh blank. */
+ openRevokeDialog(leaflet: any): void {
+ this.dialog
+ .open(RevokeDdDialogComponent, {
+ width: '480px',
+ maxWidth: '95vw',
+ data: { leaflet },
+ disableClose: true
+ })
+ .afterClosed()
+ .subscribe((result) => {
+ if (result) {
+ this.loadLeaflets();
+ }
+ });
+ }
}
// ── ISSUE DRAFT DIALOG COMPONENT ──
@@ -1655,6 +1681,114 @@ export class RequestReprintDialogComponent implements OnInit {
}
}
+/**
+ * Replacing a leaflet ruined at the printer.
+ *
+ * Not a reprint: a reprint puts the same draft back through the printer on the same paper, which is
+ * right when the print jammed but the leaflet survived. Here the leaflet itself is spoiled — its
+ * printed number can never be handed to anyone — so the draft moves onto the next blank and both
+ * leaflets stay on the register, or the pad comes up short against the sponsor bank's range.
+ */
+@Component({
+ selector: 'mifosx-revoke-dd-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ ReactiveFormsModule,
+ MatDialogModule,
+ MatFormFieldModule,
+ MatInputModule,
+ MatButtonModule
+ ],
+ template: `
+
+ Use this when the leaflet itself is ruined — misprinted, torn or jammed — and cannot be given to the customer.
+ On approval this leaflet is marked REVOKED and the draft is re-issued on the next available
+ blank.
+
+
+ No money moves. {{ data.leaflet.clientName || data.leaflet.walkinPurchaserName || 'The purchaser' }} has already
+ paid for this draft and is still owed the same one.
+
+ A leaflet ruined at the printer cannot be handed over, and its number can never be given to anyone else.
+ Approving moves the same draft onto the next available blank — this series first, then this sponsor bank, then
+ any other. No money moves: the customer was already debited.
+
+
+
+
+
+ Search by DD No. or customer
+
+
+
+
+
+ {{ status === 'ALL' ? 'All' : status }}
+ ({{ statusCount(status) }})
+
+
+ No DD revoke requests match the selected filter.
+
+
+
+
+
diff --git a/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.ts b/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.ts
new file mode 100644
index 000000000..b9733802a
--- /dev/null
+++ b/src/app/sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component.ts
@@ -0,0 +1,469 @@
+/**
+ * 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, AfterViewInit, ViewChild, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { RouterModule } from '@angular/router';
+import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
+import { MatCardModule } from '@angular/material/card';
+import { MatTableModule, MatTableDataSource } from '@angular/material/table';
+import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
+import { MatButtonModule } from '@angular/material/button';
+import { MatDialog, MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } 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 { SponsorDDService } from '../sponsor-dd.service';
+
+/**
+ * The queue for drafts spoiled at the printer.
+ *
+ * Kept apart from reprint approvals because the two decisions are not alike. A reprint puts the
+ * same draft back through the printer on the same leaflet and consumes nothing. Approving a revoke
+ * spends a blank from a limited pad the sponsor bank sent — so the checker is told which customer's
+ * draft it is, for how much, and that a fresh leaflet will be taken.
+ */
+@Component({
+ selector: 'mifosx-dd-revoke-approvals',
+ standalone: true,
+ imports: [
+ CommonModule,
+ RouterModule,
+ MatCardModule,
+ MatTableModule,
+ MatPaginatorModule,
+ MatButtonModule,
+ MatDialogModule,
+ MatSnackBarModule,
+ MatFormFieldModule,
+ MatInputModule
+ ],
+ templateUrl: './dd-revoke-approvals.component.html',
+ styles: [
+ `
+ .approvals-container {
+ padding: 24px;
+ max-width: 1240px;
+ margin: 0 auto;
+ }
+ .header-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 24px;
+ }
+ .title-area h2 {
+ margin: 0 0 4px 0;
+ font-size: 24px;
+ font-weight: 600;
+ color: #1e293b;
+ }
+ .title-area p {
+ margin: 0;
+ color: #64748b;
+ font-size: 14px;
+ }
+ .search-field {
+ width: 320px;
+ display: block;
+ margin-bottom: 8px;
+ }
+ .mat-mdc-card {
+ border-radius: 12px;
+ border: 1px solid #e2e8f0;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+ overflow: hidden;
+ }
+ table {
+ width: 100%;
+ }
+ th {
+ font-weight: 600;
+ color: #475569;
+ background-color: #f8fafc;
+ }
+ td {
+ color: #334155;
+ }
+ .status-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 8px;
+ border-radius: 9999px;
+ font-size: 12px;
+ font-weight: 500;
+ }
+ .status-badge.pending {
+ background-color: #fef3c7;
+ color: #b45309;
+ }
+ .status-badge.approved {
+ background-color: #dcfce7;
+ color: #15803d;
+ }
+ .status-badge.rejected {
+ background-color: #fee2e2;
+ color: #b91c1c;
+ }
+ .action-btn-group {
+ display: flex;
+ gap: 8px;
+ }
+ .status-filter-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 16px;
+ }
+ .status-filter-row button.filter-active {
+ background-color: #ede9fe;
+ }
+ .filter-count {
+ color: #64748b;
+ margin-left: 4px;
+ }
+ /* The replacement is the whole point of the decision, so it reads as a number rather than
+ sitting in the row as ordinary text. */
+ .replacement-no {
+ font-weight: 600;
+ color: #15803d;
+ }
+ .muted {
+ color: #94a3b8;
+ }
+ `
+ ]
+})
+export class DdRevokeApprovalsComponent implements OnInit, AfterViewInit {
+ private sponsorDDService = inject(SponsorDDService);
+ private dialog = inject(MatDialog);
+ private snackBar = inject(MatSnackBar);
+
+ @ViewChild(MatPaginator) paginator!: MatPaginator;
+
+ displayedColumns: string[] = [
+ 'id',
+ 'ddNumber',
+ 'clientName',
+ 'beneficiaryName',
+ 'faceValue',
+ 'reason',
+ 'replacementDdNumber',
+ 'status',
+ 'makerUsername',
+ 'createdDate',
+ 'actions'
+ ];
+ dataSource = new MatTableDataSource([]);
+
+ readonly statusOptions: string[] = [
+ 'PENDING',
+ 'APPROVED',
+ 'REJECTED',
+ 'ALL'
+ ];
+ selectedStatus = 'PENDING';
+ searchText = '';
+
+ ngOnInit(): void {
+ this.dataSource.filterPredicate = (data) => {
+ const statusMatches = this.selectedStatus === 'ALL' || data.status === this.selectedStatus;
+ const needle = this.searchText.trim().toLowerCase();
+ const searchMatches =
+ !needle ||
+ (data.ddNumber || '').toLowerCase().includes(needle) ||
+ (data.clientName || '').toLowerCase().includes(needle);
+ return statusMatches && searchMatches;
+ };
+ this.loadRequests();
+ }
+
+ ngAfterViewInit(): void {
+ this.dataSource.paginator = this.paginator;
+ }
+
+ loadRequests(): void {
+ this.sponsorDDService.getAllRevokeRequests().subscribe({
+ next: (data) => {
+ this.dataSource.data = data || [];
+ this.applyFilter();
+ },
+ error: (err) => {
+ this.snackBar.open('Error loading revoke requests: ' + (err.error?.message || err.message), 'Close', {
+ duration: 5000
+ });
+ }
+ });
+ }
+
+ statusCount(status: string): number {
+ const all = this.dataSource.data;
+ return status === 'ALL' ? all.length : all.filter((r) => r.status === status).length;
+ }
+
+ filterByStatus(status: string): void {
+ this.selectedStatus = status;
+ this.applyFilter();
+ }
+
+ onSearchChange(value: string): void {
+ this.searchText = value;
+ this.applyFilter();
+ }
+
+ private applyFilter(): void {
+ this.dataSource.filter = `${this.selectedStatus}|${this.searchText}`;
+ if (this.dataSource.paginator) {
+ this.dataSource.paginator.firstPage();
+ }
+ }
+
+ approve(request: any): void {
+ this.dialog
+ .open(ApproveRevokeRequestDialogComponent, {
+ width: '460px',
+ maxWidth: '95vw',
+ data: { request },
+ disableClose: true
+ })
+ .afterClosed()
+ .subscribe((confirmed) => {
+ if (!confirmed) {
+ return;
+ }
+ this.sponsorDDService.approveRevokeRequest(request.id).subscribe({
+ next: (res: any) => {
+ // The replacement number is what the clerk has to write on and hand over, so it is said
+ // plainly rather than left for them to find by reopening the register.
+ const replacement = res?.subResourceId ? ` Replacement DD issued — leaflet #${res.subResourceId}.` : '';
+ this.snackBar.open(`DD ${request.ddNumber} revoked.${replacement}`, 'Close', { duration: 6000 });
+ this.loadRequests();
+ },
+ error: (err) => {
+ this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 7000 });
+ }
+ });
+ });
+ }
+
+ openRejectDialog(request: any): void {
+ this.dialog
+ .open(RejectRevokeRequestDialogComponent, {
+ width: '450px',
+ data: { request },
+ disableClose: true
+ })
+ .afterClosed()
+ .subscribe((result) => {
+ if (result) {
+ this.loadRequests();
+ }
+ });
+ }
+}
+
+/**
+ * What approving actually costs, laid out before it is done.
+ *
+ * A browser `confirm()` would run the customer, the amount and the fact that a blank is consumed
+ * together in one sentence under the browser's own "localhost says" chrome. This is a decision
+ * about someone else's draft and about stock that cannot be replaced, so it reads like one.
+ */
+@Component({
+ selector: 'mifosx-approve-revoke-request-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ MatDialogModule,
+ MatButtonModule
+ ],
+ template: `
+
Approve DD Revoke
+
+
+
+ Spoiled DD
+ {{ data.request.ddNumber }}
+
+
+ Purchaser
+ {{ data.request.clientName }}
+
+
+ In favour of
+ {{ data.request.beneficiaryName }}
+
+
+ Face value
+ ₹{{ data.request.faceValue | number: '1.2-2' }}
+
+
+
+
Reason given {{ data.request.reason }}
+
+
+ This marks {{ data.request.ddNumber }} as REVOKED and re-issues the same draft on the next available blank —
+ preferring this series, then this sponsor bank, then any other. No money moves: the customer was already debited
+ and is still owed this one draft.
+
+ The leaflet stays issued and no replacement is taken. The clerk keeps a draft they say they cannot hand over, so
+ the reason should tell them what to do instead.
+
+
+
+
+ Cancel
+
+ Reject
+
+
+ `,
+ styles: [
+ `
+ .full-width {
+ width: 100%;
+ }
+ .hint-text {
+ font-size: 13px;
+ color: #64748b;
+ margin: 0 0 12px;
+ line-height: 1.5;
+ }
+ `
+ ]
+})
+export class RejectRevokeRequestDialogComponent {
+ private fb = inject(FormBuilder);
+ private sponsorDDService = inject(SponsorDDService);
+ private snackBar = inject(MatSnackBar);
+ dialogRef = inject(MatDialogRef);
+ data = inject<{ request: any }>(MAT_DIALOG_DATA);
+
+ rejectForm: FormGroup;
+ loading = false;
+
+ constructor() {
+ this.rejectForm = this.fb.group({
+ rejectionReason: [
+ '',
+ Validators.required
+ ]
+ });
+ }
+
+ onSubmit(): void {
+ if (this.rejectForm.invalid) {
+ return;
+ }
+ this.loading = true;
+ this.sponsorDDService.rejectRevokeRequest(this.data.request.id, this.rejectForm.value.rejectionReason).subscribe({
+ next: () => {
+ this.loading = false;
+ this.snackBar.open('Revoke request rejected', 'Close', { duration: 3000 });
+ this.dialogRef.close(true);
+ },
+ error: (err) => {
+ this.loading = false;
+ this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
+ }
+ });
+ }
+}
diff --git a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts
index 3426b8bda..b4516b3c4 100644
--- a/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts
+++ b/src/app/sponsor-dd/gl-settings-dialog/gl-settings-dialog.component.ts
@@ -206,7 +206,9 @@ export class GlSettingsDialogComponent implements OnInit {
loadGlAccountsAndMapping(): void {
this.loading = true;
- this.http.get('/glaccounts?manualEntriesAllowed=true&disabled=false').subscribe({
+ // usage=1 (Detail only): these settings map real Sponsor DD transactions onto a GL account, and
+ // a Header account can never receive one, so it must never be offered here.
+ this.http.get('/glaccounts?manualEntriesAllowed=true&disabled=false&usage=1').subscribe({
next: (accounts) => {
this.glAccounts = accounts || [];
this.sponsorDDService.getGlMappings().subscribe({
diff --git a/src/app/sponsor-dd/sponsor-dd-routing.module.ts b/src/app/sponsor-dd/sponsor-dd-routing.module.ts
index d9d6c3500..4f5609233 100644
--- a/src/app/sponsor-dd/sponsor-dd-routing.module.ts
+++ b/src/app/sponsor-dd/sponsor-dd-routing.module.ts
@@ -13,6 +13,7 @@ import { DdInventoryListComponent } from './dd-inventory-list/dd-inventory-list.
import { DdListComponent } from './dd-list/dd-list.component';
import { DdIssueApprovalsComponent } from './dd-issue-approvals/dd-issue-approvals.component';
import { DdReprintApprovalsComponent } from './dd-reprint-approvals/dd-reprint-approvals.component';
+import { DdRevokeApprovalsComponent } from './dd-revoke-approvals/dd-revoke-approvals.component';
const routes: Routes = [
Route.withShell([
@@ -44,6 +45,11 @@ const routes: Routes = [
path: 'reprint-approvals',
component: DdReprintApprovalsComponent,
data: { title: 'DD Reprint Approvals', breadcrumb: 'Reprint Approvals' }
+ },
+ {
+ path: 'revoke-approvals',
+ component: DdRevokeApprovalsComponent,
+ data: { title: 'DD Revoke Approvals', breadcrumb: 'Revoke Approvals' }
}
]
}
diff --git a/src/app/sponsor-dd/sponsor-dd.service.ts b/src/app/sponsor-dd/sponsor-dd.service.ts
index 7f6e34ff4..707a5a9da 100644
--- a/src/app/sponsor-dd/sponsor-dd.service.ts
+++ b/src/app/sponsor-dd/sponsor-dd.service.ts
@@ -218,6 +218,27 @@ export class SponsorDDService {
return this.http.post(`/sponsordd/reprint-requests/${id}/reject`, { rejectionReason });
}
+ // ── DD revoke requests (maker-checker) ──
+ // A leaflet ruined at the printer. Its number can never be handed to anyone, so the draft moves
+ // onto the next available blank and both leaflets stay on the register — one spoiled, one
+ // carrying the draft. Distinct from a reprint, which re-prints the same leaflet.
+ requestRevoke(leafletId: number, reason: string): Observable {
+ return this.http.post(`/sponsordd/leaflets/${leafletId}/revoke-request`, { reason });
+ }
+
+ getAllRevokeRequests(status?: string): Observable {
+ const params = status ? { status } : {};
+ return this.http.get('/sponsordd/revoke-requests', { params });
+ }
+
+ approveRevokeRequest(id: number): Observable {
+ return this.http.post(`/sponsordd/revoke-requests/${id}/approve`, {});
+ }
+
+ rejectRevokeRequest(id: number, rejectionReason: string): Observable {
+ return this.http.post(`/sponsordd/revoke-requests/${id}/reject`, { rejectionReason });
+ }
+
// ── DD amendment requests (maker-checker) ──
// Correcting an issued draft's beneficiary, or the branch and city it is payable at. Nothing else
// is amendable: the money behind the face value has already moved, so a draft wrong in that way
diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html
index 5fab7cf1b..9a5bb2c85 100644
--- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html
+++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html
@@ -226,6 +226,12 @@
DD Reprint Requests
+
+
+
+ DD Revoke Requests
+