feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports

This commit is contained in:
2026-09-14 11:43:26 +05:30
parent 7afa9c7a50
commit 2124bb4b76
40 changed files with 3028 additions and 929 deletions
@@ -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: `
<h2 mat-dialog-title class="approve-title">
{{ data.isBatch ? 'Approve DD Slip' : 'Approve DD Issuance' }}
</h2>
<mat-dialog-content>
<div class="approve-summary">
<div class="approve-row">
<span class="approve-label">{{ data.isBatch ? 'Drafts' : 'DD Number' }}</span>
<strong class="approve-value">{{ data.isBatch ? data.lines.length + ' drafts' : data.ddNumber }}</strong>
</div>
<div class="approve-row" *ngIf="!data.isBatch">
<span class="approve-label">In favour of</span>
<strong class="approve-value">{{ data.beneficiaryName || '—' }}</strong>
</div>
<div class="approve-row" *ngIf="data.clientName">
<span class="approve-label">Purchaser</span>
<strong class="approve-value">{{ data.clientName }}</strong>
</div>
<div class="approve-row approve-row--total">
<span class="approve-label">{{ data.isBatch ? 'Combined face value' : 'Face value' }}</span>
<strong class="approve-amount">₹{{ data.totalFaceValue | number: '1.2-2' }}</strong>
</div>
</div>
<!-- A slip is one debit for every draft on it, which is the part an approver is most likely
to get wrong if it is not said plainly. -->
<ul class="approve-lines" *ngIf="data.isBatch">
<li *ngFor="let l of data.lines">{{ l.beneficiaryName }} — ₹{{ l.faceValue | number: '1.2-2' }}</li>
</ul>
<p class="approve-effect">
{{ data.effect }}
</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button mat-flat-button color="primary" type="button" (click)="dialogRef.close(true)">
{{ data.isBatch ? 'Approve Slip' : 'Approve &amp; Issue' }}
</button>
</mat-dialog-actions>
`,
styles: [
`
.approve-title {
margin-bottom: 4px;
}
.approve-summary {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 4px 14px;
margin-bottom: 12px;
background: #f8fafc;
}
.approve-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 8px 0;
border-bottom: 1px solid #eef2f7;
font-size: 13px;
}
.approve-row:last-child {
border-bottom: none;
}
.approve-label {
color: #64748b;
}
.approve-value {
text-align: right;
}
.approve-amount {
font-size: 17px;
font-weight: 700;
}
.approve-lines {
margin: 0 0 12px;
padding-left: 20px;
font-size: 12.5px;
color: #334155;
max-height: 160px;
overflow-y: auto;
}
.approve-effect {
margin: 0;
padding: 10px 12px;
border-radius: 6px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 12.5px;
line-height: 1.5;
}
`
]
})
export class ApproveIssueRequestDialogComponent {
dialogRef = inject(MatDialogRef<ApproveIssueRequestDialogComponent>);
data = inject(MAT_DIALOG_DATA);
}
// ── REJECT DIALOG COMPONENT ──
@Component({
selector: 'mifosx-reject-issue-request-dialog',