diff --git a/src/app/cashier/cashier-routing.module.ts b/src/app/cashier/cashier-routing.module.ts
index 76947f25b..f4c7d8401 100644
--- a/src/app/cashier/cashier-routing.module.ts
+++ b/src/app/cashier/cashier-routing.module.ts
@@ -83,6 +83,13 @@ const routes: Routes = [
),
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
},
+ {
+ // The same page as the cashier's, reached from the clerk dashboard: a clerk unpasses vouchers here.
+ path: 'voucher-correction',
+ loadComponent: () =>
+ import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
+ data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
+ },
{
path: 'closure-requests',
loadComponent: () =>
@@ -291,6 +298,12 @@ const routes: Routes = [
component: ReconciliationComponent,
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
},
+ {
+ path: 'voucher-correction',
+ loadComponent: () =>
+ import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
+ data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
+ },
{
path: 'cash-history',
component: CashHistoryComponent,
diff --git a/src/app/cashier/day-book/day-book.component.html b/src/app/cashier/day-book/day-book.component.html
index 5c5215145..116009136 100644
--- a/src/app/cashier/day-book/day-book.component.html
+++ b/src/app/cashier/day-book/day-book.component.html
@@ -202,6 +202,7 @@
Amount
Payment
Status
+ Correction
By
Approved By
Time
@@ -231,6 +232,18 @@
{{ r.status }}
+
+
+
+ @if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
+ —
+ }
+
{{ r.handledBy ?? '—' }}
{{ r.approvedBy ?? '—' }}
@@ -256,7 +269,7 @@
—
}
-
+
diff --git a/src/app/cashier/day-book/day-book.component.ts b/src/app/cashier/day-book/day-book.component.ts
index 05fb8e475..59bac8801 100644
--- a/src/app/cashier/day-book/day-book.component.ts
+++ b/src/app/cashier/day-book/day-book.component.ts
@@ -14,6 +14,7 @@ import { Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
+import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/**
* Which part of the day's work a row came from. Set by the server, one per source register.
@@ -26,6 +27,11 @@ type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM';
interface DayBookRow {
voucherNo: string | null;
+ /** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
+ voucherStatus?: VoucherStatus;
+ revokedByVoucherNo?: string | null;
+ replacesVoucherNo?: string | null;
+ correctionNote?: string | null;
section: DayBookSection;
side: 'CR' | 'DR';
kind: string;
@@ -58,7 +64,8 @@ interface DayBookRow {
imports: [
CommonModule,
FormsModule,
- MatIconModule
+ MatIconModule,
+ VoucherStatusComponent
]
})
export class DayBookComponent implements OnInit {
diff --git a/src/app/cashier/my-work/my-work.component.html b/src/app/cashier/my-work/my-work.component.html
index e4c96e7db..e62edb863 100644
--- a/src/app/cashier/my-work/my-work.component.html
+++ b/src/app/cashier/my-work/my-work.component.html
@@ -146,6 +146,7 @@
Amount
My part
Status
+ Correction
Time
@@ -203,6 +204,18 @@
{{ statusLabel(r.verificationStatus) }}
+
+
+
+ @if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
+ —
+ }
+
{{ r.createdAt | date: 'HH:mm' }}
}
diff --git a/src/app/cashier/my-work/my-work.component.ts b/src/app/cashier/my-work/my-work.component.ts
index 8c0cf4bd4..28ef84cc3 100644
--- a/src/app/cashier/my-work/my-work.component.ts
+++ b/src/app/cashier/my-work/my-work.component.ts
@@ -15,6 +15,7 @@ import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
+import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -24,6 +25,11 @@ export type VoucherSide = 'CR' | 'DR' | 'JV';
interface MyWorkRow {
voucherNo: string;
+ /** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
+ voucherStatus?: VoucherStatus;
+ revokedByVoucherNo?: string | null;
+ replacesVoucherNo?: string | null;
+ correctionNote?: string | null;
side: VoucherSide | null;
voucherType: string | null;
amount: number | null;
@@ -76,7 +82,8 @@ interface MyWorkReport {
CommonModule,
FormsModule,
MatIconModule,
- MatDialogModule
+ MatDialogModule,
+ VoucherStatusComponent
]
})
export class MyWorkComponent implements OnInit {
diff --git a/src/app/cashier/my-work/voucher-detail-dialog.component.ts b/src/app/cashier/my-work/voucher-detail-dialog.component.ts
index 93137c3c7..a50339751 100644
--- a/src/app/cashier/my-work/voucher-detail-dialog.component.ts
+++ b/src/app/cashier/my-work/voucher-detail-dialog.component.ts
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
+import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
export interface VoucherDetailDialogData {
/** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
@@ -18,6 +19,10 @@ export interface VoucherDetailDialogData {
* duplicated inside every dialog that could ever show a voucher. */
detail: {
voucherNo: string;
+ voucherStatus?: VoucherStatus;
+ revokedByVoucherNo?: string | null;
+ replacesVoucherNo?: string | null;
+ correctionNote?: string | null;
voucherType: string | null;
amount: number | null;
branchName: string | null;
@@ -58,7 +63,8 @@ export interface VoucherDetailDialogData {
CommonModule,
MatDialogModule,
MatButtonModule,
- MatIconModule
+ MatIconModule,
+ VoucherStatusComponent
],
template: `
@@ -66,7 +72,15 @@ export interface VoucherDetailDialogData {
diff --git a/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.html b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.html
new file mode 100644
index 000000000..f65551611
--- /dev/null
+++ b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.html
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
undo
+
+
Voucher Corrections
+
+ Approve or reject a voucher unpass, and the correction entry that follows it. Only the correction approval
+ moves money.
+
+
+
+
+ {{ unpassCount }} unpass
+ {{ correctionCount }} correction
+
+
+
+
+
+
+ search
+
+ @if (searchTerm) {
+
+ close
+
+ }
+
+
+
+
+
+ @for (opt of kindOptions; track opt.value) {
+ {{ opt.label }}
+ }
+
+
+
+ refresh
+
+
+
+
+ @if (loading && !loaded) {
+
+ } @else if (rows.length === 0) {
+
+
inbox
+
No voucher correction waiting
+
There is no unpass request or correction entry waiting for review.
+
+ } @else {
+
+
+
+
+ Voucher No
+ Request
+ Posted To (Wrong)
+ Correct Account
+ Amount
+ Reason
+ Raised By
+ Raised At
+ Action
+
+
+
+ @for (r of rows; track r.request.id) {
+
+ {{ r.request.original_voucher_no }}
+
+ {{ kindLabel(r.kind) }}
+
+
+ {{ r.request.wrong_account_no }}
+ {{ r.request.wrong_client_name || '—' }}
+
+
+ @if (r.kind === 'correction') {
+ {{ r.request.correct_account_no }}
+ {{ r.request.correct_client_name || '—' }}
+ } @else {
+ —
+ }
+
+ ₹{{ r.request.amount | number: '1.2-2' }}
+ {{ r.request.reason || '—' }}
+ {{ r.raisedBy || '—' }}
+ {{ formatDateTime(r.raisedAt) }}
+
+
+ close Reject
+
+
+ check
+ {{ r.kind === 'unpass' ? 'Approve unpass' : 'Approve correction' }}
+
+
+
+ }
+
+
+
+ }
+
+
+
+
+ @if (rejecting) {
+
+
+
+ Reason (required)
+
+
+
+
+ }
+
diff --git a/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.scss b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.scss
new file mode 100644
index 000000000..c8fbb6097
--- /dev/null
+++ b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.scss
@@ -0,0 +1,485 @@
+/**
+ * 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/.
+ */
+
+// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
+:host {
+ display: block;
+}
+
+.vca-tab {
+ padding: 0;
+}
+
+.vca-action-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 10px 16px;
+ background: #fff;
+ border-bottom: 1px solid #e2e8f0;
+
+ &__left {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ &__icon {
+ font-size: 22px;
+ width: 22px;
+ height: 22px;
+ color: #0f4f3c;
+ }
+
+ &__title {
+ font-size: 13px;
+ font-weight: 700;
+ color: #0d1b2a;
+ }
+
+ &__sub {
+ max-width: 640px;
+ margin-top: 1px;
+ font-size: 11px;
+ color: #64748b;
+ }
+}
+
+.vca-counts {
+ display: flex;
+ gap: 8px;
+}
+
+.vca-count {
+ padding: 2px 10px;
+ border-radius: 999px;
+ background: #f1f5f9;
+ font-size: 11px;
+ color: #475569;
+
+ strong {
+ color: #0f4f3c;
+ }
+}
+
+.vca-filter-bar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ padding: 6px 16px;
+ background: #f8fafc;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.vca-fb-sep {
+ flex-shrink: 0;
+ width: 1px;
+ height: 22px;
+ margin: 0 4px;
+ background: #e2e8f0;
+}
+
+.vca-search-box {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 30px;
+ min-width: 240px;
+ padding: 0 10px;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ background: #fff;
+
+ mat-icon {
+ flex-shrink: 0;
+ font-size: 17px;
+ width: 17px;
+ height: 17px;
+ color: #94a3b8;
+ }
+
+ &:hover {
+ border-color: #cbd5e1;
+ }
+
+ &:focus-within {
+ border-color: #0f4f3c;
+ box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
+ }
+
+ input {
+ flex: 1;
+ border: none;
+ outline: none;
+ background: transparent;
+ font-family: inherit;
+ font-size: 12px;
+ color: #0d1b2a;
+
+ &::placeholder {
+ color: #94a3b8;
+ }
+ }
+
+ &__clear {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ background: none;
+ color: #94a3b8;
+ cursor: pointer;
+
+ mat-icon {
+ font-size: 15px;
+ width: 15px;
+ height: 15px;
+ color: inherit;
+ }
+
+ &:hover {
+ color: #dc2626;
+ background: #fef2f2;
+ }
+ }
+}
+
+.vca-filter-select {
+ height: 30px;
+ min-width: 140px;
+ padding: 0 10px;
+ border: 1px solid #e2e8f0;
+ border-radius: 7px;
+ background: #fff;
+ font-family: inherit;
+ font-size: 12px;
+ color: #0d1b2a;
+ cursor: pointer;
+
+ &:focus {
+ outline: none;
+ border-color: #0f4f3c;
+ }
+}
+
+// ── Buttons ─────────────────────────────────────────────────────────────────
+.vca-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ height: 26px;
+ padding: 0 10px;
+ border: 1px solid transparent;
+ border-radius: 7px;
+ font-family: inherit;
+ font-size: 11px;
+ font-weight: 600;
+ white-space: nowrap;
+ cursor: pointer;
+
+ mat-icon {
+ font-size: 14px;
+ width: 14px;
+ height: 14px;
+ }
+
+ &:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ }
+
+ &--refresh {
+ height: 30px;
+ width: 30px;
+ justify-content: center;
+ padding: 0;
+ margin-left: auto;
+ border-color: #e2e8f0;
+ background: #fff;
+ color: #475569;
+ }
+
+ &--approve {
+ background: #0f4f3c;
+ color: #fff;
+
+ &:hover:not(:disabled) {
+ background: #0a3d2e;
+ }
+ }
+
+ &--reject {
+ border-color: #fecaca;
+ background: #fff;
+ color: #dc2626;
+
+ &:hover:not(:disabled) {
+ background: #fef2f2;
+ }
+ }
+
+ &--reject-solid {
+ background: #dc2626;
+ color: #fff;
+ }
+
+ &--ghost {
+ border-color: #e2e8f0;
+ background: #fff;
+ color: #475569;
+ }
+}
+
+// ── Table ───────────────────────────────────────────────────────────────────
+.vca-table-wrap {
+ overflow-x: auto;
+ background: #fff;
+}
+
+.vca-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 11px;
+
+ th {
+ padding: 7px 10px;
+ border-bottom: 1px solid #e2e8f0;
+ background: #f8fafc;
+ font-size: 9.5px;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-align: left;
+ text-transform: uppercase;
+ color: #94a3b8;
+ white-space: nowrap;
+ }
+
+ td {
+ padding: 5px 10px;
+ border-bottom: 1px solid #eef2f7;
+ vertical-align: middle;
+ color: #0d1b2a;
+ }
+
+ .num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ }
+
+ .vca-th-action {
+ text-align: right;
+ }
+}
+
+.vca-row:hover td {
+ background: #f8fafc;
+}
+
+.vca-mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ letter-spacing: 0.02em;
+}
+
+.vca-voucher {
+ font-weight: 700;
+ color: #0f4f3c;
+}
+
+.vca-strong {
+ font-weight: 700;
+}
+
+.vca-sub {
+ margin-top: 1px;
+ font-size: 11px;
+ color: #64748b;
+}
+
+.vca-none {
+ color: #cbd5e1;
+}
+
+.vca-amount {
+ font-weight: 700;
+}
+
+.vca-reason {
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.vca-dt {
+ white-space: nowrap;
+ color: #475569;
+}
+
+.vca-kind {
+ display: inline-block;
+ padding: 2px 10px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+
+ &--unpass {
+ background: #fef3c7;
+ color: #92400e;
+ }
+
+ &--correction {
+ background: #dbeafe;
+ color: #1e40af;
+ }
+}
+
+.vca-action-cell {
+ display: flex;
+ justify-content: flex-end;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+// ── Empty ───────────────────────────────────────────────────────────────────
+.vca-empty {
+ padding: 28px 16px;
+ text-align: center;
+ color: #94a3b8;
+
+ &__icon {
+ display: block;
+ margin: 0 auto 6px;
+ font-size: 30px;
+ width: 30px;
+ height: 30px;
+ opacity: 0.6;
+ }
+
+ &__title {
+ margin: 0;
+ font-size: 12px;
+ font-weight: 700;
+ color: #64748b;
+ }
+
+ &__text {
+ margin: 3px 0 0;
+ font-size: 11px;
+ }
+}
+
+// ── Reject popup ────────────────────────────────────────────────────────────
+.vca-dlg {
+ font-size: 12px;
+
+ &__head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ border-bottom: 1px solid #e2e8f0;
+ background: #f8fafc;
+ }
+
+ &__ico {
+ flex: 0 0 32px;
+ height: 32px;
+ width: 32px;
+ border-radius: 9px;
+ background: #fef2f2;
+ color: #dc2626;
+ font-size: 18px;
+ line-height: 32px;
+ text-align: center;
+ }
+
+ &__titles {
+ flex: 1;
+ min-width: 0;
+ }
+
+ &__title {
+ font-size: 14px;
+ font-weight: 700;
+ color: #0f172a;
+ }
+
+ &__sub {
+ font-size: 11px;
+ color: #64748b;
+ }
+
+ &__x {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 28px;
+ width: 28px;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: #64748b;
+ cursor: pointer;
+
+ &:hover {
+ background: #e2e8f0;
+ }
+
+ mat-icon {
+ font-size: 18px;
+ height: 18px;
+ width: 18px;
+ }
+ }
+
+ &__body {
+ padding: 12px 14px;
+
+ textarea {
+ width: 100%;
+ padding: 7px 10px;
+ border: 1px solid #cbd5e1;
+ border-radius: 8px;
+ font: inherit;
+ font-size: 12px;
+ resize: vertical;
+
+ &:focus {
+ outline: 0;
+ border-color: #0f4f3c;
+ box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
+ }
+ }
+ }
+
+ &__foot {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 10px 14px;
+ border-top: 1px solid #e2e8f0;
+ background: #f8fafc;
+ }
+}
+
+.vca-label {
+ display: block;
+ margin-bottom: 4px;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: #94a3b8;
+}
diff --git a/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.ts b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.ts
new file mode 100644
index 000000000..56fe1650c
--- /dev/null
+++ b/src/app/cashier/voucher-correction-approvals/voucher-correction-approvals.component.ts
@@ -0,0 +1,192 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
+import { MatIconModule } from '@angular/material/icon';
+import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+import { of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+import { VoucherCorrection, VoucherCorrectionService } from '../voucher-correction.service';
+
+type Kind = 'unpass' | 'correction';
+type KindFilter = 'ALL' | Kind;
+
+/** One row of the approvals table: a request, and which of the two steps it is waiting at. */
+interface ApprovalRow {
+ kind: Kind;
+ request: VoucherCorrection;
+ raisedBy: string | null;
+ raisedAt: string | null;
+}
+
+/**
+ * Manager side of the voucher correction, laid out like the other approval tabs: approves or rejects the unpass of a
+ * voucher, and later the correction entry the cashier made. Only the second approval moves money.
+ */
+@Component({
+ selector: 'mifosx-voucher-correction-approvals',
+ templateUrl: './voucher-correction-approvals.component.html',
+ styleUrls: ['./voucher-correction-approvals.component.scss'],
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatDialogModule,
+ MatIconModule,
+ MatSnackBarModule
+ ]
+})
+export class VoucherCorrectionApprovalsComponent implements OnInit {
+ private service = inject(VoucherCorrectionService);
+ private snackBar = inject(MatSnackBar);
+ private dialog = inject(MatDialog);
+
+ @ViewChild('rejectDialog') rejectDialog!: TemplateRef
;
+ private dialogRef: MatDialogRef | null = null;
+
+ all: ApprovalRow[] = [];
+ loading = false;
+ loaded = false;
+ busyId: number | null = null;
+
+ kindFilter: KindFilter = 'ALL';
+ searchTerm = '';
+
+ /** The row being rejected, and the reason typed for it. */
+ rejecting: ApprovalRow | null = null;
+ rejectReason = '';
+
+ readonly kindOptions: { value: KindFilter; label: string }[] = [
+ { value: 'ALL', label: 'All requests' },
+ { value: 'unpass', label: 'Unpass requests' },
+ { value: 'correction', label: 'Correction entries' }
+ ];
+
+ ngOnInit(): void {
+ this.load();
+ }
+
+ load(): void {
+ this.loading = true;
+ this.service
+ .list('UNPASS_PENDING,CORRECTION_PENDING')
+ .pipe(catchError(() => of([] as VoucherCorrection[])))
+ .subscribe((rows) => {
+ this.all = rows.map((r) =>
+ r.status === 'UNPASS_PENDING'
+ ? { kind: 'unpass', request: r, raisedBy: r.unpass_by_name, raisedAt: r.unpass_at }
+ : { kind: 'correction', request: r, raisedBy: r.correction_by_name, raisedAt: r.correction_at }
+ );
+ this.loading = false;
+ this.loaded = true;
+ });
+ }
+
+ /** The table: the kind picked, narrowed by whatever is typed in the search box. */
+ get rows(): ApprovalRow[] {
+ const term = this.searchTerm.trim().toLowerCase();
+ return this.all.filter((r) => {
+ if (this.kindFilter !== 'ALL' && r.kind !== this.kindFilter) return false;
+ if (!term) return true;
+ const q = r.request;
+ return [
+ q.original_voucher_no,
+ q.wrong_account_no,
+ q.wrong_client_name,
+ q.correct_account_no,
+ q.correct_client_name,
+ r.raisedBy
+ ].some((v) => (v ?? '').toLowerCase().includes(term));
+ });
+ }
+
+ get unpassCount(): number {
+ return this.all.filter((r) => r.kind === 'unpass').length;
+ }
+
+ get correctionCount(): number {
+ return this.all.filter((r) => r.kind === 'correction').length;
+ }
+
+ kindLabel(kind: Kind): string {
+ return kind === 'unpass' ? 'Unpass' : 'Correction';
+ }
+
+ formatDateTime(value: string | null): string {
+ if (!value) return '—';
+ const d = new Date(value);
+ return isNaN(d.getTime())
+ ? value
+ : d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
+ }
+
+ approve(row: ApprovalRow): void {
+ if (this.busyId) return;
+ const id = row.request.id;
+ this.busyId = id;
+ const call = row.kind === 'unpass' ? this.service.approveUnpass(id) : this.service.approveCorrection(id);
+ call.subscribe({
+ next: (res: any) => {
+ this.busyId = null;
+ this.snackBar.open(
+ row.kind === 'unpass'
+ ? `Voucher ${row.request.original_voucher_no} unpassed — the cashier can now enter the correct account`
+ : `Corrected. New voucher ${res?.newVoucherNo ?? ''}`,
+ 'Close',
+ { duration: 6000 }
+ );
+ this.load();
+ },
+ error: (err) => {
+ this.busyId = null;
+ this.fail(err, 'Could not approve');
+ }
+ });
+ }
+
+ startReject(row: ApprovalRow): void {
+ this.rejecting = row;
+ this.rejectReason = '';
+ this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
+ this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
+ }
+
+ cancelReject(): void {
+ this.dialogRef?.close();
+ }
+
+ confirmReject(): void {
+ if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
+ const row = this.rejecting;
+ const id = row.request.id;
+ this.busyId = id;
+ const reason = this.rejectReason.trim();
+ const call =
+ row.kind === 'unpass' ? this.service.rejectUnpass(id, reason) : this.service.rejectCorrection(id, reason);
+ call.subscribe({
+ next: () => {
+ this.busyId = null;
+ this.dialogRef?.close();
+ this.snackBar.open('Rejected', 'Close', { duration: 4000 });
+ this.load();
+ },
+ error: (err) => {
+ this.busyId = null;
+ this.fail(err, 'Could not reject');
+ }
+ });
+ }
+
+ private fail(err: any, fallback: string): void {
+ const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
+ this.snackBar.open(msg, 'Close', { duration: 8000 });
+ }
+}
diff --git a/src/app/cashier/voucher-correction.service.ts b/src/app/cashier/voucher-correction.service.ts
new file mode 100644
index 000000000..d252a561a
--- /dev/null
+++ b/src/app/cashier/voucher-correction.service.ts
@@ -0,0 +1,120 @@
+/**
+ * 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 { Injectable, inject } from '@angular/core';
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Observable } from 'rxjs';
+
+/** One row of a voucher correction, as the server keeps it (snake_case columns). */
+export interface VoucherCorrection {
+ id: number;
+ original_voucher_no: string;
+ original_txn_id: number;
+ wrong_savings_account_id: number;
+ wrong_account_no: string;
+ wrong_client_name: string | null;
+ amount: number;
+ txn_date: string | null;
+ status: 'UNPASS_PENDING' | 'UNPASSED' | 'UNPASS_REJECTED' | 'CORRECTION_PENDING' | 'COMPLETED' | 'CLOSED';
+ reason: string | null;
+ rejection_reason: string | null;
+ unpass_by_name: string | null;
+ unpass_at: string | null;
+ unpass_approved_by_name: string | null;
+ correct_account_no: string | null;
+ correct_client_name: string | null;
+ correction_by_name: string | null;
+ correction_at: string | null;
+ correction_approved_by_name: string | null;
+ new_voucher_no: string | null;
+}
+
+/** A cash deposit voucher of today that can still be unpassed. */
+export interface EligibleVoucher {
+ voucherNo: string;
+ amount: number;
+ createdAt: string;
+ cashier: string;
+ txnId: number;
+ savingsAccountId: number;
+ accountNo: string;
+ clientName: string | null;
+ paymentType: string;
+}
+
+/** What the server knows about a savings account typed in as the correct one. */
+export interface AccountLookup {
+ id: number;
+ accountNo: string;
+ clientId: number | null;
+ clientName: string | null;
+ balance: number;
+ currencyCode: string;
+ canReceive: boolean;
+}
+
+/** HTTP service for the voucher correction workflow: unpass, approve, re-enter, approve. */
+@Injectable({ providedIn: 'root' })
+export class VoucherCorrectionService {
+ private http = inject(HttpClient);
+ private readonly base = '/vouchers/corrections';
+
+ eligible(search?: string): Observable {
+ let params = new HttpParams();
+ if (search) params = params.set('search', search);
+ return this.http.get(`${this.base}/eligible`, { params });
+ }
+
+ unpass(voucherNo: string, reason: string): Observable {
+ return this.http.post(`${this.base}/unpass`, { voucherNo, reason });
+ }
+
+ list(status?: string, mine = false): Observable {
+ let params = new HttpParams().set('mine', String(mine));
+ if (status) params = params.set('status', status);
+ return this.http.get(this.base, { params });
+ }
+
+ get(id: number): Observable {
+ return this.http.get(`${this.base}/${id}`);
+ }
+
+ accountLookup(accountNo: string): Observable {
+ return this.http.get(`${this.base}/account-lookup`, { params: { accountNo } });
+ }
+
+ submitCorrection(id: number, correctAccountNo: string): Observable {
+ return this.http.post(`${this.base}/${id}/correction`, { correctAccountNo });
+ }
+
+ /** The cashier gives up the correction, e.g. when the wrong account no longer holds the money. */
+ close(id: number, reason: string): Observable {
+ return this.http.post(`${this.base}/${id}/close`, { reason });
+ }
+
+ approveUnpass(id: number): Observable {
+ return this.http.post(`${this.base}/${id}/unpass/approve`, {});
+ }
+
+ rejectUnpass(id: number, reason: string): Observable {
+ return this.http.post(`${this.base}/${id}/unpass/reject`, { reason });
+ }
+
+ approveCorrection(id: number): Observable {
+ return this.http.post(`${this.base}/${id}/correction/approve`, {});
+ }
+
+ rejectCorrection(id: number, reason: string): Observable {
+ return this.http.post(`${this.base}/${id}/correction/reject`, { reason });
+ }
+
+ /** Ids of the transactions of this account that a completed correction removed. */
+ voidedTransactions(savingsAccountId: number): Observable {
+ return this.http.get(`${this.base}/voided-transactions`, { params: { savingsAccountId } });
+ }
+}
diff --git a/src/app/cashier/voucher-correction/voucher-correction.component.html b/src/app/cashier/voucher-correction/voucher-correction.component.html
new file mode 100644
index 000000000..6c113b6d2
--- /dev/null
+++ b/src/app/cashier/voucher-correction/voucher-correction.component.html
@@ -0,0 +1,446 @@
+
+
+
+
+
+
+
+
+ 1
+ Unpass the voucher
+
+
+ 2
+ Manager approves
+
+
+ 3
+ Enter the correct account
+
+
+ 4
+ Manager approves, deposit moves
+
+
+
+
+ @if (mayUnpass) {
+
+ receipt_long Today's Vouchers
+
+ }
+ @if (mayCorrect) {
+
+ edit_note Unpassed — Correct Entry
+ @if (unpassed.length) {
+ {{ unpassed.length }}
+ }
+
+ }
+
+ history My Requests
+
+
+
+
+ @if (tab === 'today') {
+
+
+
+
+
+
+ Voucher
+ Account
+ Customer
+ Mode
+ Cashier
+ Amount
+
+
+
+
+ @for (v of vouchers; track v.voucherNo) {
+
+
+ {{ v.voucherNo }}
+
+ {{ v.accountNo }}
+ {{ v.clientName || '—' }}
+ {{ v.paymentType }}
+ {{ v.cashier }}
+ ₹{{ v.amount | number: '1.2-2' }}
+
+
+ undo Unpass
+
+
+
+ }
+ @if (!vouchers.length) {
+
+
+ {{ loadingVouchers ? 'hourglass_empty' : 'inbox' }}
+ {{ loadingVouchers ? 'Loading…' : 'No cash deposit voucher of today can be unpassed.' }}
+
+
+ }
+
+
+
+ }
+
+
+ @if (tab === 'unpassed') {
+
+
+
+
+ Voucher
+ Posted to
+ Customer
+ Reason
+ Amount
+
+
+
+
+ @for (u of unpassed; track u.id) {
+
+
+ {{ u.original_voucher_no }}
+
+ {{ u.wrong_account_no }}
+ {{ u.wrong_client_name || '—' }}
+ {{ u.reason }}
+ ₹{{ u.amount | number: '1.2-2' }}
+
+
+ edit Correct
+
+
+
+ }
+ @if (!unpassed.length) {
+
+
+ {{ loadingUnpassed ? 'hourglass_empty' : 'task_alt' }}
+
+ {{ loadingUnpassed ? 'Loading…' : 'No unpassed voucher is waiting for its correct account.' }}
+
+
+
+ }
+
+
+
+ }
+
+
+ @if (tab === 'mine') {
+
+
+
+
+ Voucher
+ Posted to
+ Corrected to
+ Amount
+ Status
+ New voucher
+
+
+
+ @for (m of mine; track m.id) {
+
+
+ {{ m.original_voucher_no }}
+
+ {{ m.wrong_account_no }}
+ {{ m.correct_account_no || '—' }}
+ ₹{{ m.amount | number: '1.2-2' }}
+
+ {{ statusLabel[m.status] }}
+ @if (m.rejection_reason) {
+ {{ m.rejection_reason }}
+ }
+
+ {{ m.new_voucher_no || '—' }}
+
+ }
+ @if (!mine.length) {
+
+
+ {{ loadingMine ? 'hourglass_empty' : 'history' }}
+ {{ loadingMine ? 'Loading…' : 'You have no voucher corrections yet.' }}
+
+
+ }
+
+
+
+ }
+
+
+
+
+ @if (selectedVoucher) {
+
+
+
+
+
+ Deposit amount
+ ₹{{ selectedVoucher.amount | number: '1.2-2' }}
+
+
+
+
+
Voucher
+
{{ selectedVoucher.voucherNo }}
+
+
+
Created
+
{{ selectedVoucher.createdAt | date: 'dd MMM yyyy, hh:mm a' }}
+
+
+
Cashier
+
{{ selectedVoucher.cashier }}
+
+
+
Mode
+
{{ selectedVoucher.paymentType }}
+
+
+
Account
+
{{ selectedVoucher.accountNo }}
+
+
+
Customer
+
{{ selectedVoucher.clientName || '—' }}
+
+
+
Account balance now
+
+ @if (voucherAccount) {
+ ₹{{ voucherAccount.balance | number: '1.2-2' }}
+ } @else {
+ —
+ }
+
+
+
+
Account status
+
+ @if (voucherAccount) {
+ {{ voucherAccount.canReceive ? 'Active' : 'Not active' }} · {{ voucherAccount.currencyCode }}
+ } @else {
+ —
+ }
+
+
+
+
+
Reason for unpassing
+
+
+
+ info
+ A manager approves the unpass first. Nothing changes on any account until the corrected entry is approved: the
+ deposit stays on {{ selectedVoucher.accountNo }} till then.
+
+
+
+
+
+ }
+
+
+
+
+ @if (active) {
+
+
+
+
+
+ Amount to move
+ ₹{{ active.amount | number: '1.2-2' }}
+
+
+
+
+
+
Transaction date
+
{{ active.txn_date || '—' }}
+
+
+
+
Unpassed by
+
{{ active.unpass_by_name || '—' }}
+
+
+
Approved by
+
{{ active.unpass_approved_by_name || '—' }}
+
+
+
Reason
+
{{ active.reason }}
+
+
+
+
+
+
+
Posted to (wrong)
+
{{ active.wrong_account_no }}
+
{{ active.wrong_client_name || '—' }}
+
+ Balance:
+ @if (wrongAccount) {
+ ₹{{ wrongAccount.balance | number: '1.2-2' }}
+ @if (wrongHasBalance) {
+ check_circle covers the deposit
+ } @else {
+ cancel does not cover ₹{{ active.amount | number: '1.2-2' }}
+ }
+ } @else {
+ —
+ }
+
+
+
+
arrow_forward
+
+
+
+
Correct account
+
+ @if (lookingUp) {
+
Looking up…
+ }
+ @if (correctAccount) {
+
{{ correctAccount.clientName || '—' }}
+
+ Balance: ₹{{ correctAccount.balance | number: '1.2-2' }}
+ @if (correctOk) {
+ check_circle active, can receive
+ }
+
+ }
+ @if (lookupError) {
+
error {{ lookupError }}
+ }
+
+
+
+ @if (wrongAccount && !wrongHasBalance) {
+
+
warning
+
+ The customer has already used this money, so it cannot be taken back from
+ {{ active.wrong_account_no }}. Deposit ₹{{ active.amount | number: '1.2-2' }} into the correct account
+ from your own pocket as a fresh deposit (Transaction Entry), then press Close request .
+
+
+ }
+
+
+
+
+ }
+
diff --git a/src/app/cashier/voucher-correction/voucher-correction.component.scss b/src/app/cashier/voucher-correction/voucher-correction.component.scss
new file mode 100644
index 000000000..3242e02c5
--- /dev/null
+++ b/src/app/cashier/voucher-correction/voucher-correction.component.scss
@@ -0,0 +1,599 @@
+/**
+ * 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/.
+ */
+
+// Compact sizing: the page has to fit a 100% browser window without scrolling for the common case.
+.pg {
+ padding: 14px 20px;
+ gap: 12px;
+}
+
+.pg-title {
+ font-size: 20px;
+ margin: 2px 0;
+}
+
+.pg-sub {
+ font-size: 12px;
+}
+
+.pg-btn {
+ height: 30px;
+ padding: 0 12px;
+ font-size: 12px;
+
+ mat-icon {
+ font-size: 16px;
+ height: 16px;
+ width: 16px;
+ }
+
+ &:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ }
+}
+
+.pg-table {
+ font-size: 12px;
+
+ th {
+ padding: 8px 12px;
+ font-size: 10px;
+ }
+
+ td {
+ padding: 7px 12px;
+ }
+}
+
+// ── Steps ───────────────────────────────────────────────────────────────────
+.vc-steps {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
+ gap: 8px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.vc-step {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ background: #fff;
+ font-size: 12px;
+ color: #64748b;
+
+ &__no {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: #e2e8f0;
+ font-size: 11px;
+ font-weight: 700;
+ color: #475569;
+ }
+
+ &--on {
+ border-color: #1f7a4d;
+ background: #f0fdf4;
+ color: #0f4f3c;
+
+ .vc-step__no {
+ background: #1f7a4d;
+ color: #fff;
+ }
+ }
+}
+
+// ── Tabs ────────────────────────────────────────────────────────────────────
+.vc-tabs {
+ display: flex;
+ gap: 2px;
+ border-bottom: 1px solid #e2e8f0;
+ flex-wrap: wrap;
+}
+
+.vc-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 7px 12px;
+ margin-bottom: -1px;
+ border: 0;
+ border-bottom: 2px solid transparent;
+ background: transparent;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ color: #64748b;
+ cursor: pointer;
+
+ mat-icon {
+ font-size: 16px;
+ height: 16px;
+ width: 16px;
+ }
+
+ &:hover {
+ color: #0f4f3c;
+ }
+
+ &--on {
+ color: #0f4f3c;
+ border-bottom-color: #1f7a4d;
+ }
+}
+
+.vc-count {
+ padding: 0 7px;
+ border-radius: 999px;
+ background: #f59e0b;
+ color: #fff;
+ font-size: 10px;
+ font-weight: 700;
+}
+
+// ── Search ──────────────────────────────────────────────────────────────────
+.vc-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.vc-search {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex: 1 1 280px;
+ max-width: 420px;
+ height: 30px;
+ padding: 0 10px;
+ border: 1px solid #cbd5e1;
+ border-radius: 999px;
+ background: #fff;
+
+ &:focus-within {
+ border-color: #1f7a4d;
+ box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
+ }
+
+ mat-icon {
+ font-size: 16px;
+ height: 16px;
+ width: 16px;
+ color: #94a3b8;
+ }
+
+ input {
+ flex: 1;
+ min-width: 0;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ font: inherit;
+ font-size: 12px;
+ }
+}
+
+// ── Table ───────────────────────────────────────────────────────────────────
+.vc-table {
+ tbody tr {
+ cursor: pointer;
+ }
+
+ tr.vc-row--on td {
+ background: #f0fdf4;
+
+ &:first-child {
+ box-shadow: inset 3px 0 0 #1f7a4d;
+ }
+ }
+
+ .vc-col-action {
+ width: 1%;
+ text-align: right;
+ white-space: nowrap;
+ }
+
+ .vc-clip {
+ max-width: 240px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .pg-chip {
+ font-size: 11px;
+ padding: 2px 8px;
+ }
+}
+
+.vc-btn-sm {
+ height: 26px;
+ padding: 0 10px;
+ font-size: 11px;
+}
+
+.vc-mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ letter-spacing: 0.02em;
+}
+
+.vc-empty {
+ padding: 24px 12px;
+ text-align: center;
+ color: #94a3b8;
+ font-size: 12px;
+
+ mat-icon {
+ display: block;
+ margin: 0 auto 4px;
+ font-size: 28px;
+ height: 28px;
+ width: 28px;
+ opacity: 0.6;
+ }
+}
+
+// ── Popups ──────────────────────────────────────────────────────────────────
+.vc-dlg {
+ display: flex;
+ flex-direction: column;
+ max-height: 86vh;
+ font-size: 12px;
+
+ &__head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ border-bottom: 1px solid #e2e8f0;
+ background: #f8fafc;
+ }
+
+ &__ico {
+ flex: 0 0 32px;
+ height: 32px;
+ width: 32px;
+ border-radius: 9px;
+ background: #f0fdf4;
+ color: #1f7a4d;
+ font-size: 18px;
+ line-height: 32px;
+ text-align: center;
+ }
+
+ &__titles {
+ flex: 1;
+ min-width: 0;
+ }
+
+ &__title {
+ font-size: 14px;
+ font-weight: 700;
+ color: #0f172a;
+ }
+
+ &__sub {
+ font-size: 11px;
+ color: #64748b;
+ }
+
+ &__x {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 28px;
+ width: 28px;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: #64748b;
+ cursor: pointer;
+
+ mat-icon {
+ font-size: 18px;
+ height: 18px;
+ width: 18px;
+ }
+
+ &:hover {
+ background: #e2e8f0;
+ }
+ }
+
+ &__body {
+ padding: 12px 14px;
+ overflow-y: auto;
+ }
+
+ &__amount {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ margin-bottom: 10px;
+ padding: 8px 12px;
+ border-radius: 10px;
+ background: #ecfdf5;
+ border: 1px solid #a7f3d0;
+ color: #065f46;
+ font-size: 12px;
+ font-weight: 600;
+
+ strong {
+ font-size: 18px;
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+ }
+ }
+
+ &__foot {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 10px 14px;
+ border-top: 1px solid #e2e8f0;
+ background: #f8fafc;
+ }
+}
+
+.vc-warn {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ margin-top: 10px;
+ padding: 8px 12px;
+ border-radius: 8px;
+ background: #fffbeb;
+ border: 1px solid #fde68a;
+ color: #92400e;
+ font-size: 11px;
+
+ mat-icon {
+ flex: 0 0 18px;
+ height: 18px;
+ width: 18px;
+ font-size: 18px;
+ }
+}
+
+.vc-dlg__close-req {
+ margin-right: auto;
+ color: #b45309;
+}
+
+.vc-label {
+ display: block;
+ margin-bottom: 3px;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: #94a3b8;
+}
+
+.vc-reason {
+ width: 100%;
+ padding: 7px 10px;
+ border: 1px solid #cbd5e1;
+ border-radius: 8px;
+ font: inherit;
+ font-size: 12px;
+ resize: vertical;
+
+ &:focus {
+ outline: 0;
+ border-color: #1f7a4d;
+ box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
+ }
+}
+
+.vc-info {
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+ margin-top: 8px;
+ padding: 7px 10px;
+ border-radius: 8px;
+ background: #eff6ff;
+ color: #1e40af;
+ font-size: 11px;
+
+ mat-icon {
+ flex: 0 0 16px;
+ height: 16px;
+ width: 16px;
+ font-size: 16px;
+ }
+}
+
+.vc-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
+ gap: 6px;
+ margin-bottom: 10px;
+}
+
+.vc-cell {
+ padding: 6px 10px;
+ border-radius: 8px;
+ background: #f8fafc;
+ border: 1px solid #eef2f7;
+}
+
+.vc-cell--wide {
+ grid-column: 1 / -1;
+}
+
+.vc-value {
+ font-size: 12px;
+ font-weight: 600;
+ color: #0f172a;
+ word-break: break-word;
+}
+
+// ── Wrong → Correct ─────────────────────────────────────────────────────────
+.vc-compare {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: stretch;
+ gap: 8px;
+
+ @media (width <= 640px) {
+ grid-template-columns: 1fr;
+ }
+}
+
+.vc-arrow {
+ align-self: center;
+ color: #94a3b8;
+
+ @media (width <= 640px) {
+ transform: rotate(90deg);
+ justify-self: center;
+ }
+}
+
+.vc-acct {
+ padding: 10px 12px;
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ background: #fff;
+
+ &--wrong {
+ border-color: #fecaca;
+ background: #fef2f2;
+ }
+
+ &--ok {
+ border-color: #a7f3d0;
+ background: #ecfdf5;
+ }
+
+ &__tag {
+ margin-bottom: 4px;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: #64748b;
+ }
+
+ &__no {
+ font-size: 14px;
+ font-weight: 700;
+ color: #0f172a;
+ }
+
+ &__input {
+ width: 100%;
+ height: 32px;
+ padding: 0 10px;
+ border: 1px solid #cbd5e1;
+ border-radius: 8px;
+ background: #fff;
+ font-size: 13px;
+ font-weight: 600;
+
+ &:focus {
+ outline: 0;
+ border-color: #1f7a4d;
+ box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
+ }
+ }
+
+ &__name {
+ margin-top: 4px;
+ font-size: 12px;
+ font-weight: 600;
+ color: #1e293b;
+ }
+
+ &__bal {
+ margin-top: 3px;
+ font-size: 11px;
+ color: #475569;
+ }
+
+ &__err {
+ margin-top: 6px;
+ }
+}
+
+.vc-ok,
+.vc-bad {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ margin-left: 6px;
+ font-weight: 700;
+
+ mat-icon {
+ font-size: 14px;
+ height: 14px;
+ width: 14px;
+ }
+}
+
+.vc-ok {
+ color: #059669;
+}
+
+.vc-bad {
+ color: #dc2626;
+}
+
+.vc-note {
+ margin-top: 3px;
+ font-size: 11px;
+ color: #64748b;
+}
+
+// ── Status ──────────────────────────────────────────────────────────────────
+.vc-status {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: #e2e8f0;
+ font-size: 11px;
+ font-weight: 700;
+
+ &--UNPASS_PENDING,
+ &--CORRECTION_PENDING {
+ background: #fef3c7;
+ color: #92400e;
+ }
+
+ &--UNPASSED {
+ background: #dbeafe;
+ color: #1e40af;
+ }
+
+ &--COMPLETED {
+ background: #d1fae5;
+ color: #065f46;
+ }
+
+ &--CLOSED {
+ background: #f1f5f9;
+ color: #475569;
+ }
+
+ &--UNPASS_REJECTED {
+ background: #fee2e2;
+ color: #991b1b;
+ }
+}
diff --git a/src/app/cashier/voucher-correction/voucher-correction.component.ts b/src/app/cashier/voucher-correction/voucher-correction.component.ts
new file mode 100644
index 000000000..db114d5c2
--- /dev/null
+++ b/src/app/cashier/voucher-correction/voucher-correction.component.ts
@@ -0,0 +1,307 @@
+/**
+ * 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, TemplateRef, ViewChild, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
+import { MatIconModule } from '@angular/material/icon';
+import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+import { of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+import { AuthenticationService } from '../../core/authentication/authentication.service';
+import {
+ AccountLookup,
+ EligibleVoucher,
+ VoucherCorrection,
+ VoucherCorrectionService
+} from '../voucher-correction.service';
+
+type Tab = 'today' | 'unpassed' | 'mine';
+
+/**
+ * Cashier side of correcting a cash deposit that went to the wrong account.
+ *
+ * Today vouchers: pick the voucher and unpass it (needs a manager approval; nothing moves).
+ * Unpassed: open the approved voucher, whose details are fixed, change only the account number and save
+ * (needs a manager approval again; only then do the accounts change).
+ */
+@Component({
+ selector: 'mifosx-voucher-correction',
+ templateUrl: './voucher-correction.component.html',
+ styleUrls: [
+ '../cashier-shared.scss',
+ './voucher-correction.component.scss'
+ ],
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatIconModule,
+ MatDialogModule,
+ MatSnackBarModule
+ ]
+})
+export class VoucherCorrectionComponent implements OnInit {
+ private service = inject(VoucherCorrectionService);
+ private snackBar = inject(MatSnackBar);
+ private dialog = inject(MatDialog);
+ private auth = inject(AuthenticationService);
+
+ /** Unpassing is for the back office (an administrator or a clerk); re-entering the account is the cashier only. */
+ readonly mayUnpass = this.auth.isClerkUser() || !!this.auth.getCredentials()?.permissions?.includes('ALL_FUNCTIONS');
+ readonly mayCorrect = this.auth.isCashierUser();
+
+ @ViewChild('unpassDialog') unpassDialog!: TemplateRef;
+ @ViewChild('correctionDialog') correctionDialog!: TemplateRef;
+ private dialogRef: MatDialogRef | null = null;
+
+ tab: Tab = this.mayUnpass ? 'today' : this.mayCorrect ? 'unpassed' : 'mine';
+
+ // Today vouchers
+ search = '';
+ vouchers: EligibleVoucher[] = [];
+ loadingVouchers = false;
+ selectedVoucher: EligibleVoucher | null = null;
+ /** The account the voucher was posted to, for the balance and status shown in the popup. */
+ voucherAccount: AccountLookup | null = null;
+ unpassReason = '';
+ sending = false;
+
+ // Unpassed vouchers waiting for the correct account
+ unpassed: VoucherCorrection[] = [];
+ loadingUnpassed = false;
+ active: VoucherCorrection | null = null;
+ wrongAccount: AccountLookup | null = null;
+ correctAccountNo = '';
+ correctAccount: AccountLookup | null = null;
+ lookupError = '';
+ lookingUp = false;
+
+ // The cashier own requests
+ mine: VoucherCorrection[] = [];
+ loadingMine = false;
+
+ readonly statusLabel: Record = {
+ UNPASS_PENDING: 'Unpass awaiting approval',
+ UNPASSED: 'Unpassed — enter correct account',
+ UNPASS_REJECTED: 'Unpass rejected',
+ CORRECTION_PENDING: 'Correction awaiting approval',
+ COMPLETED: 'Corrected',
+ CLOSED: 'Closed — cashier deposits separately'
+ };
+
+ ngOnInit(): void {
+ if (this.mayUnpass) this.loadVouchers();
+ if (this.mayCorrect) this.loadUnpassed();
+ if (this.tab === 'mine') this.loadMine();
+ }
+
+ setTab(tab: Tab): void {
+ this.tab = tab;
+ if (tab === 'today') this.loadVouchers();
+ if (tab === 'unpassed') this.loadUnpassed();
+ if (tab === 'mine') this.loadMine();
+ }
+
+ // ── Today vouchers ─────────────────────────────────────────────────────────
+
+ loadVouchers(): void {
+ this.loadingVouchers = true;
+ this.service
+ .eligible(this.search.trim())
+ .pipe(catchError(() => of([] as EligibleVoucher[])))
+ .subscribe((rows) => {
+ this.vouchers = rows;
+ this.loadingVouchers = false;
+ if (this.selectedVoucher && !rows.some((r) => r.voucherNo === this.selectedVoucher?.voucherNo)) {
+ this.selectedVoucher = null;
+ }
+ });
+ }
+
+ pick(v: EligibleVoucher): void {
+ this.selectedVoucher = v;
+ this.unpassReason = '';
+ this.voucherAccount = null;
+ this.service
+ .accountLookup(v.accountNo)
+ .pipe(catchError(() => of(null)))
+ .subscribe((a) => (this.voucherAccount = a));
+ this.showDialog(this.unpassDialog, () => (this.selectedVoucher = null));
+ }
+
+ closeUnpass(): void {
+ this.dialogRef?.close();
+ }
+
+ get canUnpass(): boolean {
+ return !!this.selectedVoucher && this.unpassReason.trim().length > 0 && !this.sending;
+ }
+
+ unpass(): void {
+ if (!this.selectedVoucher || !this.canUnpass) return;
+ this.sending = true;
+ this.service.unpass(this.selectedVoucher.voucherNo, this.unpassReason.trim()).subscribe({
+ next: () => {
+ this.sending = false;
+ this.snackBar.open(`Voucher ${this.selectedVoucher?.voucherNo} sent for unpass approval`, 'Close', {
+ duration: 4000
+ });
+ this.dialogRef?.close();
+ this.unpassReason = '';
+ this.loadVouchers();
+ },
+ error: (err) => {
+ this.sending = false;
+ this.fail(err, 'Could not unpass the voucher');
+ }
+ });
+ }
+
+ // ── Unpassed vouchers: enter the correct account ───────────────────────────
+
+ loadUnpassed(): void {
+ this.loadingUnpassed = true;
+ this.service
+ .list('UNPASSED')
+ .pipe(catchError(() => of([] as VoucherCorrection[])))
+ .subscribe((rows) => {
+ this.unpassed = rows;
+ this.loadingUnpassed = false;
+ });
+ }
+
+ open(row: VoucherCorrection): void {
+ this.active = row;
+ this.correctAccountNo = '';
+ this.correctAccount = null;
+ this.lookupError = '';
+ this.wrongAccount = null;
+ // The account the deposit went to: its balance is what the "can this be taken back" check reads.
+ this.service
+ .accountLookup(row.wrong_account_no)
+ .pipe(catchError(() => of(null)))
+ .subscribe((a) => (this.wrongAccount = a));
+ this.showDialog(this.correctionDialog, () => (this.active = null));
+ }
+
+ closeActive(): void {
+ this.dialogRef?.close();
+ }
+
+ onAccountInput(value: string): void {
+ this.correctAccountNo = value;
+ this.correctAccount = null;
+ this.lookupError = '';
+ // Same rule as the Transaction Entry screen: searches on its own once six characters are typed.
+ if (value.trim().length >= 6) this.lookup();
+ }
+
+ lookup(): void {
+ const accountNo = this.correctAccountNo.trim();
+ if (!accountNo || this.lookingUp) return;
+ this.lookingUp = true;
+ this.service.accountLookup(accountNo).subscribe({
+ next: (a) => {
+ this.lookingUp = false;
+ // A late answer for a number no longer typed is dropped.
+ if (accountNo !== this.correctAccountNo.trim()) return;
+ this.correctAccount = a;
+ if (this.active && a.id === this.active.wrong_savings_account_id) {
+ this.lookupError = 'This is the account the deposit was already posted to.';
+ } else if (!a.canReceive) {
+ this.lookupError = 'This account is not active, so it cannot receive the deposit.';
+ }
+ },
+ error: (err) => {
+ this.lookingUp = false;
+ this.lookupError = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Account not found.';
+ }
+ });
+ }
+
+ /** The wrong account still holds the deposit, so it can be taken back without overdrawing it. */
+ get wrongHasBalance(): boolean {
+ return !!this.wrongAccount && !!this.active && this.wrongAccount.balance >= this.active.amount;
+ }
+
+ get correctOk(): boolean {
+ return !!this.correctAccount && this.correctAccount.canReceive && !this.lookupError;
+ }
+
+ get canSave(): boolean {
+ return !!this.active && this.correctOk && this.wrongHasBalance && !this.sending;
+ }
+
+ save(): void {
+ if (!this.active || !this.canSave) return;
+ this.sending = true;
+ this.service.submitCorrection(this.active.id, this.correctAccountNo.trim()).subscribe({
+ next: () => {
+ this.sending = false;
+ this.snackBar.open('Correction sent for approval', 'Close', { duration: 4000 });
+ this.dialogRef?.close();
+ this.loadUnpassed();
+ },
+ error: (err) => {
+ this.sending = false;
+ this.fail(err, 'Could not save the correction');
+ }
+ });
+ }
+
+ /**
+ * Gives up this correction. The usual reason is that the wrong account no longer holds the money, so the cashier
+ * puts the amount into the right account from their own pocket as a fresh deposit instead.
+ */
+ closeRequest(): void {
+ if (!this.active || this.sending) return;
+ const reason = this.wrongHasBalance
+ ? 'Closed by the cashier'
+ : 'Wrong account no longer holds the amount; cashier deposits it into the correct account separately';
+ this.sending = true;
+ this.service.close(this.active.id, reason).subscribe({
+ next: () => {
+ this.sending = false;
+ this.snackBar.open('Request closed', 'Close', { duration: 4000 });
+ this.dialogRef?.close();
+ this.loadUnpassed();
+ },
+ error: (err) => {
+ this.sending = false;
+ this.fail(err, 'Could not close the request');
+ }
+ });
+ }
+
+ // ── My requests ────────────────────────────────────────────────────────────
+
+ loadMine(): void {
+ this.loadingMine = true;
+ this.service
+ .list(undefined, true)
+ .pipe(catchError(() => of([] as VoucherCorrection[])))
+ .subscribe((rows) => {
+ this.mine = rows;
+ this.loadingMine = false;
+ });
+ }
+
+ /** Opens one of the two popups; onClose runs however it is dismissed (button, backdrop or Esc). */
+ private showDialog(template: TemplateRef, onClose: () => void): void {
+ this.dialogRef?.close();
+ this.dialogRef = this.dialog.open(template, { width: '600px', maxWidth: '95vw', autoFocus: false });
+ this.dialogRef.afterClosed().subscribe(onClose);
+ }
+
+ private fail(err: any, fallback: string): void {
+ const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
+ this.snackBar.open(msg, 'Close', { duration: 6000 });
+ }
+}
diff --git a/src/app/cashier/voucher-report/voucher-report.component.html b/src/app/cashier/voucher-report/voucher-report.component.html
index 73ac94478..592554b20 100644
--- a/src/app/cashier/voucher-report/voucher-report.component.html
+++ b/src/app/cashier/voucher-report/voucher-report.component.html
@@ -169,6 +169,7 @@
Debit
Credit
Amount
+ Correction
User
+
+
+ @if (!row.voucherStatus || row.voucherStatus === 'ACTIVE') {
+ —
+ }
+
{{ row.username || '—' }}
@if (systemOnly) {
diff --git a/src/app/cashier/voucher-report/voucher-report.component.scss b/src/app/cashier/voucher-report/voucher-report.component.scss
index 1fe7a3b97..d7e7cc51e 100644
--- a/src/app/cashier/voucher-report/voucher-report.component.scss
+++ b/src/app/cashier/voucher-report/voucher-report.component.scss
@@ -92,3 +92,8 @@
text-transform: uppercase;
color: #7c3aed;
}
+
+.vr-active {
+ font-size: 11px;
+ color: #94a3b8;
+}
diff --git a/src/app/cashier/voucher-report/voucher-report.component.ts b/src/app/cashier/voucher-report/voucher-report.component.ts
index d6c148d91..3d04bd8a6 100644
--- a/src/app/cashier/voucher-report/voucher-report.component.ts
+++ b/src/app/cashier/voucher-report/voucher-report.component.ts
@@ -14,6 +14,7 @@ import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
+import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/**
* Which side of the register a screen shows.
@@ -26,6 +27,11 @@ export type VoucherSide = 'CR' | 'DR' | 'ALL';
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
interface VoucherRow {
voucherNo: string;
+ /** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
+ voucherStatus?: VoucherStatus;
+ revokedByVoucherNo?: string | null;
+ replacesVoucherNo?: string | null;
+ correctionNote?: string | null;
voucherType: string | null;
amount: number | null;
createdAt: string | null;
@@ -82,7 +88,8 @@ interface VoucherReport {
imports: [
CommonModule,
FormsModule,
- MatIconModule
+ MatIconModule,
+ VoucherStatusComponent
]
})
export class VoucherReportComponent implements OnInit {
diff --git a/src/app/cashier/voucher-status/voucher-status.component.ts b/src/app/cashier/voucher-status/voucher-status.component.ts
new file mode 100644
index 000000000..a436de861
--- /dev/null
+++ b/src/app/cashier/voucher-status/voucher-status.component.ts
@@ -0,0 +1,137 @@
+/**
+ * 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, Input } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+/** Where a voucher stands after a voucher correction. */
+export type VoucherStatus =
+ | 'ACTIVE'
+ | 'REVOKED'
+ | 'UNPASS_PENDING'
+ | 'UNPASSED'
+ | 'CORRECTION_PENDING'
+ | 'CORRECTED'
+ | 'CLOSED'
+ | null
+ | undefined;
+
+/**
+ * A small badge saying whether a voucher was revoked, is under correction, or replaced another one.
+ * Nothing is shown for an ordinary (active) voucher, so the registers read as before.
+ */
+@Component({
+ selector: 'mifosx-voucher-status',
+ standalone: true,
+ imports: [CommonModule],
+ template: `
+ @if (label) {
+ {{ label }}
+ @if (note) {
+ {{ note }}
+ }
+ }
+ `,
+ styles: [
+ `
+ .vs {
+ display: inline-block;
+ margin-left: 6px;
+ padding: 1px 8px;
+ border-radius: 999px;
+ background: #e2e8f0;
+ color: #334155;
+ font-size: 10px;
+ font-weight: 700;
+ white-space: nowrap;
+ vertical-align: middle;
+ }
+
+ .vs-note {
+ margin-top: 2px;
+ max-width: 280px;
+ font-size: 11px;
+ line-height: 1.3;
+ color: #64748b;
+ }
+
+ .vs--REVOKED {
+ background: #fee2e2;
+ color: #991b1b;
+ }
+
+ .vs--UNPASS_PENDING,
+ .vs--CORRECTION_PENDING {
+ background: #fef3c7;
+ color: #92400e;
+ }
+
+ .vs--UNPASSED {
+ background: #dbeafe;
+ color: #1e40af;
+ }
+
+ .vs--CLOSED {
+ background: #f1f5f9;
+ color: #475569;
+ }
+
+ .vs--CORRECTED {
+ background: #d1fae5;
+ color: #065f46;
+ }
+ `
+ ]
+})
+export class VoucherStatusComponent {
+ @Input() status: VoucherStatus;
+ /** The voucher that replaced this one, when it is revoked. */
+ @Input() revokedBy: string | null | undefined;
+ /** The voucher this one replaced, when it is the corrected one. */
+ @Input() replaces: string | null | undefined;
+ /** Why: the reason the voucher was unpassed, or why the correction was closed. */
+ @Input() note: string | null | undefined;
+
+ get label(): string {
+ switch (this.status) {
+ case 'REVOKED':
+ return this.revokedBy ? `Revoked → ${this.revokedBy}` : 'Revoked';
+ case 'UNPASS_PENDING':
+ return 'Unpass pending';
+ case 'UNPASSED':
+ return 'Unpassed';
+ case 'CORRECTION_PENDING':
+ return 'Correction pending';
+ case 'CLOSED':
+ return 'Closed — cashier deposits separately';
+ case 'CORRECTED':
+ return this.replaces ? `Corrected (replaces ${this.replaces})` : 'Corrected';
+ default:
+ return '';
+ }
+ }
+
+ get hint(): string {
+ switch (this.status) {
+ case 'REVOKED':
+ return 'This voucher was revoked: its deposit was moved to another account and a new voucher replaced it.';
+ case 'UNPASS_PENDING':
+ return 'A manager has not yet approved unpassing this voucher.';
+ case 'UNPASSED':
+ return 'Unpassed: waiting for the cashier to enter the correct account.';
+ case 'CORRECTION_PENDING':
+ return 'The correct account is entered; waiting for a manager to approve.';
+ case 'CLOSED':
+ return 'The correction was closed by the cashier; the voucher stays as it was.';
+ case 'CORRECTED':
+ return 'This voucher was raised by a correction and replaces the earlier one.';
+ default:
+ return '';
+ }
+ }
+}
diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html
index 420d10c4a..dab25a011 100644
--- a/src/app/core/shell/shell.component.html
+++ b/src/app/core/shell/shell.component.html
@@ -131,6 +131,14 @@
My Closure Requests
+
+
+ Voucher Correction
+
@if (clerkAccountLoaded) {
diff --git a/src/app/savings/common-resolvers/savings-account-view.resolver.ts b/src/app/savings/common-resolvers/savings-account-view.resolver.ts
index 264fa26ff..faf79efd1 100644
--- a/src/app/savings/common-resolvers/savings-account-view.resolver.ts
+++ b/src/app/savings/common-resolvers/savings-account-view.resolver.ts
@@ -11,10 +11,12 @@ import { Injectable, inject } from '@angular/core';
import { ActivatedRouteSnapshot } from '@angular/router';
/** rxjs Imports */
-import { Observable } from 'rxjs';
+import { Observable, of } from 'rxjs';
+import { catchError, map, switchMap } from 'rxjs/operators';
/** Custom Services */
import { SavingsService } from '../savings.service';
+import { VoucherCorrectionService } from '../../cashier/voucher-correction.service';
/**
* Savings Account data resolver.
@@ -22,14 +24,29 @@ import { SavingsService } from '../savings.service';
@Injectable()
export class SavingsAccountViewResolver {
private savingsService = inject(SavingsService);
+ private voucherCorrectionService = inject(VoucherCorrectionService);
/**
- * Returns the Savings Account data.
+ * Returns the Savings Account data, without the deposits a completed voucher correction took off this account:
+ * the passbook and the transaction list read their rows from here, and a deposit that was posted to the wrong
+ * account and moved should not appear on it at all.
* @param {ActivatedRouteSnapshot} route Route Snapshot
* @returns {Observable}
*/
resolve(route: ActivatedRouteSnapshot): Observable {
const savingAccountId = route.paramMap.get('savingAccountId');
- return this.savingsService.getSavingsAccountData(savingAccountId);
+ return this.savingsService.getSavingsAccountData(savingAccountId).pipe(
+ switchMap((account: any) =>
+ this.voucherCorrectionService.voidedTransactions(Number(savingAccountId)).pipe(
+ catchError(() => of([] as number[])),
+ map((voided: number[]) => {
+ if (voided.length && Array.isArray(account?.transactions)) {
+ account.transactions = account.transactions.filter((txn: any) => !voided.includes(txn.id));
+ }
+ return account;
+ })
+ )
+ )
+ );
}
}
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 65f82890f..d8b22a6c4 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
@@ -144,6 +144,24 @@
Withdrawal Requests
+
+
+ Cash Transfers
+
+
+
+ Voucher Corrections
+
+ import('../cashier/cash-transfer-approvals/cash-transfer-approvals.component').then(
+ (m) => m.CashTransferApprovalsComponent
+ ),
+ data: { title: 'Cash Transfers' }
+ },
+ {
+ path: 'voucher-corrections',
+ loadComponent: () =>
+ import('../cashier/voucher-correction-approvals/voucher-correction-approvals.component').then(
+ (m) => m.VoucherCorrectionApprovalsComponent
+ ),
+ data: { title: 'Voucher Corrections' }
+ },
{
path: 'dividend-requests',
component: DividendRequestsComponent,