/** * Copyright since 2025 Mifos Initiative * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms'; import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; export interface CashierIssueDdDialogData { leaflet: any; branchName: string; sponsorBankName: string; faceValue: number; chargesAmount: number; gstPercentage: number; gstAmount: number; totalCashPayable: number; /** * How the draft is being paid for. Cash is a walk-in purchaser the bank may know nothing about, * so their name, address and Aadhaar have to be captured here. An account debit is a member whose * account is already on screen — those fields are not asked for, and must not be sent, because * the identity is the account holder's rather than a walk-in's. */ paymentMethod?: 'CASH' | 'ACCOUNT_DEBIT'; /** The account being debited, shown so the clerk can see what is funding the draft. */ accountNo?: string; /** The account holder, for the same reason. */ accountHolderName?: string; /** * How many drafts this slip raises. Absent or 1 is the ordinary single draft and the dialog is * unchanged; 2 or more turns it into a slip, asking for a face value, charge and beneficiary per * draft and reporting the combined total the account will be debited for. */ ddCount?: number; /** * Whether the funding account is a GL account rather than a member's. Only affects wording: a GL * account has no passbook, so it is not promised one. */ glFunded?: boolean; /** * The leaflets provisionally shown against each line of a slip. * * Only ever indicative. The server allocates the real numbers inside the locked transaction that * submits the slip, so what is shown here is the next-available run at the moment the dialog was * opened — accurate unless another clerk submits first, which is precisely why the numbers cannot * be promised until submit returns them. */ previewDdNumbers?: string[]; } /** One captured draft on a multi-DD slip. */ export interface DdSlipLineResult { beneficiaryName: string; payableAtBranch: string; payableAtCity: string; faceValue: number; chargesAmount: number; } @Component({ selector: 'mifosx-cashier-issue-dd-dialog', standalone: true, imports: [ CommonModule, FormsModule, ReactiveFormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule ], template: `
receipt_long

@if (isSlip) { Multi-DD Slip · {{ lineCount }} Demand Drafts } @else { {{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }} }

@if (isSlip) { One debit funds all {{ lineCount }} drafts · Enter each draft's beneficiary and amount } @else { Next available DD leaflet auto-selected · {{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }} }

@if (isSlip) {
Drafts On This Slip {{ lineCount }} {{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}
Total Face Value ₹{{ slipFaceValue | number: '1.2-2' }} + Charges ₹{{ slipCharges | number: '1.2-2' }}
GST ({{ data.gstPercentage }}%) ₹{{ slipGst | number: '1.2-2' }} on charges only
Total To Debit ₹{{ slipTotal | number: '1.2-2' }} {{ data.accountNo || 'Account' }} · one debit
info
DD numbers below are provisional. The final numbers are allocated when the slip is submitted and will be confirmed on the receipt.
} @else {
DD Number Being Issued {{ data.leaflet.ddNumber }} {{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}
Face Value ₹{{ data.faceValue | number: '1.2-2' }} + Charges ₹{{ data.chargesAmount | number: '1.2-2' }}
GST ({{ data.gstPercentage }}%) ₹{{ data.gstAmount | number: '1.2-2' }} on charges only
{{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }} ₹{{ data.totalCashPayable | number: '1.2-2' }} @if (isAccountDebit) { {{ data.accountNo || 'Account' }} } @else { Denominations verified }
} @if (isAccountDebit) {
account_balance_wallet
Debiting {{ data.accountNo }} @if (data.accountHolderName) { · {{ data.accountHolderName }} }
@if (isSlip) { ₹{{ slipTotal | number: '1.2-2' }} will be debited from this account as a single transaction covering all {{ lineCount }} drafts, when a checker approves the slip — @if (isGlFunded) { one journal entry against this GL account, not {{ lineCount }}. } @else { the member's passbook shows one entry, not {{ lineCount }}. } } @else { ₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker approves. No cash is taken at the counter. }
} @if (isSlip) {
@for (lineCtrl of slipLines.controls; track $index) {
DD {{ $index + 1 }} of {{ lineCount }} @if (previewNumberAt($index)) { No. {{ previewNumberAt($index) }} (provisional) } Debit ₹{{ lineTotalAt($index) | number: '1.2-2' }}
Beneficiary Name (In Favour Of) * Beneficiary name is required Max 120 characters
Payable At - Branch * Payable Branch is required Payable At - City * Payable City is required
Face Value * ₹  Face value is required Face value must be greater than zero Charges ₹  GST ₹{{ lineGstAt($index) | number: '1.2-2' }} at {{ data.gstPercentage }}% Charges cannot be negative
}
} @else {
@if (!isAccountDebit) {
Walk-in Purchaser Details
Walk-in Purchaser Name * Purchaser name is required Must be at least 3 characters 12-Digit Aadhaar Number * Masked to last 4 digits before storage Aadhaar number is required Must be exactly 12 numeric digits
Purchaser Address / Location * Address is required }
Beneficiary & Payment Details
Beneficiary Name (In Favour Of) * Beneficiary name is required Max 120 characters
Payable At - Branch * Payable Branch is required Payable At - City * Payable City is required
}
@if (isSlip) { {{ lineCount }} drafts · ₹{{ slipTotal | number: '1.2-2' }} to debit }
`, styles: [ ` .dd-dialog-shell { display: flex; flex-direction: column; max-height: 90vh; } .dd-dialog-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: #0f172a; color: #ffffff; border-top-left-radius: 8px; border-top-right-radius: 8px; } .dd-dialog-header__title { display: flex; align-items: center; gap: 12px; } .dd-dialog-header__title h2 { margin: 0; font-size: 18px; font-weight: 600; color: #ffffff; } .dd-dialog-header__title p { margin: 2px 0 0 0; font-size: 12px; color: #94a3b8; } .dd-icon { color: #38bdf8; font-size: 28px; width: 28px; height: 28px; } .close-btn { color: #94a3b8; } .close-btn:hover { color: #ffffff; } .dd-dialog-body { padding: 20px 24px; overflow-y: auto; } .dd-summary-card { display: flex; align-items: center; justify-content: space-between; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 14px 18px; margin-bottom: 20px; } .summary-col { display: flex; flex-direction: column; gap: 2px; } .summary-col.highlight-col { background: #ecfdf5; padding: 6px 12px; border-radius: 6px; border: 1px solid #a7f3d0; } .summary-divider { width: 1px; height: 36px; background: #e2e8f0; } .summary-col .label { font-size: 11px; text-transform: uppercase; color: #64748b; font-weight: 600; } .dd-number-highlight { font-size: 16px; color: #0369a1; font-family: monospace; } .summary-col .amount { font-size: 15px; color: #1e293b; } .summary-col .total-amount { font-size: 16px; color: #065f46; } .summary-col .sublabel { font-size: 11px; color: #94a3b8; } .form-section-title { font-size: 13px; font-weight: 600; text-transform: uppercase; color: #475569; margin: 16px 0 10px 0; border-bottom: 1px solid #f1f5f9; padding-bottom: 4px; } .form-section-title:first-child { margin-top: 0; } .dd-form { display: flex; flex-direction: column; gap: 8px; } .full-width { width: 100%; } .form-row { display: flex; gap: 16px; } .form-field-flex { flex: 1; } .dd-dialog-actions { padding: 12px 24px; border-top: 1px solid #e2e8f0; } /* The account about to be debited — read before the form, so a wrong one is caught here. */ .dd-funding-note { display: flex; gap: 10px; align-items: flex-start; margin: 14px 0 4px; padding: 11px 14px; background: #eff6ff; border: 1px solid #bfdbfe; border-left: 3px solid #1d4ed8; border-radius: 6px; font-size: 13px; color: #334155; } .dd-funding-note mat-icon { color: #1d4ed8; flex-shrink: 0; } .dd-funding-sub { margin-top: 3px; font-size: 11.5px; line-height: 1.5; color: #64748b; } /* One card per draft on a slip — the boundary is what stops a clerk typing line 2's beneficiary against line 1's amount. */ .dd-line-card { border: 1px solid #e2e8f0; border-left: 3px solid #0369a1; border-radius: 8px; padding: 14px 16px 4px; margin-bottom: 14px; background: #fcfdff; } .dd-line-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; } .dd-line-badge { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; color: #ffffff; background: #0369a1; padding: 3px 9px; border-radius: 999px; } .dd-line-number { font-family: monospace; font-size: 12px; color: #475569; } .dd-line-number em { font-style: normal; color: #94a3b8; font-size: 11px; } .dd-line-total { margin-left: auto; font-size: 12px; font-weight: 600; color: #065f46; background: #ecfdf5; border: 1px solid #a7f3d0; padding: 3px 10px; border-radius: 6px; } .dd-preview-note { display: flex; gap: 8px; align-items: flex-start; margin: 0 0 16px; padding: 9px 12px; background: #fffbeb; border: 1px solid #fde68a; border-radius: 6px; font-size: 12px; line-height: 1.5; color: #78350f; } .dd-preview-note mat-icon { color: #b45309; font-size: 18px; width: 18px; height: 18px; flex-shrink: 0; } .dd-actions-total { margin-right: auto; font-size: 13px; color: #475569; } .dd-actions-total strong { color: #065f46; } ` ] }) export class CashierIssueDdDialogComponent implements OnInit { private fb = inject(FormBuilder); private dialogRef = inject(MatDialogRef); public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA); form!: FormGroup; /** Built only in slip mode; `form` stays untouched so the single-draft path is unchanged. */ slipForm!: FormGroup; /** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */ get isAccountDebit(): boolean { return this.data.paymentMethod === 'ACCOUNT_DEBIT'; } /** How many drafts this dialog is capturing — 1 unless the clerk asked for a slip. */ get lineCount(): number { const n = Number(this.data.ddCount) || 1; return n > 1 ? n : 1; } /** Whether this dialog is capturing a multi-DD slip rather than a single draft. */ get isSlip(): boolean { return this.lineCount > 1; } /** Whether the debit lands on a GL account rather than a member's — wording only. */ get isGlFunded(): boolean { return !!this.data.glFunded; } /** The form the Save button validates against — whichever of the two was built. */ get activeForm(): FormGroup { return this.isSlip ? this.slipForm : this.form; } get slipLines(): FormArray { return this.slipForm.get('lines') as FormArray; } /** The provisional leaflet number shown against a line, if the caller supplied a run of them. */ previewNumberAt(index: number): string | null { return this.data.previewDdNumbers?.[index] ?? null; } private lineValueAt(index: number, field: string): number { return Number(this.slipLines.at(index)?.get(field)?.value) || 0; } /** * GST on one line's charge. * * Rounded per line, matching the server, which computes and stores GST against each draft rather * than once for the slip. Summing rounded lines and rounding a summed charge can differ by a * paisa, and it is the per-line figure that ends up on the draft. */ lineGstAt(index: number): number { const charges = this.lineValueAt(index, 'chargesAmount'); if (charges <= 0) return 0; return Math.round(charges * (Number(this.data.gstPercentage) || 0)) / 100; } /** What one line adds to the debit — its face value, its charge and the GST on that charge. */ lineTotalAt(index: number): number { return this.lineValueAt(index, 'faceValue') + this.lineValueAt(index, 'chargesAmount') + this.lineGstAt(index); } get slipFaceValue(): number { return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'faceValue'), 0); } get slipCharges(): number { return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'chargesAmount'), 0); } get slipGst(): number { return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineGstAt(i), 0); } /** The figure the account is debited for — rounded up to the whole rupee, as the single flow does. */ get slipTotal(): number { return Math.ceil(this.slipFaceValue + this.slipCharges + this.slipGst - 1e-9); } ngOnInit(): void { if (this.isSlip) { this.slipForm = this.fb.group({ lines: this.fb.array( Array.from({ length: this.lineCount }, () => this.fb.group({ beneficiaryName: [ '', [ Validators.required, Validators.maxLength(120) ] ], payableAtBranch: [ '', [Validators.required] ], payableAtCity: [ '', [Validators.required] ], // Zero is rejected rather than merely warned about: the server refuses a line with a // face value at or below zero, and a slip that fails there has already cost the clerk // every other line they typed. faceValue: [ null as | number | null, [ Validators.required, Validators.min(0.01) ] ], chargesAmount: [ 0, [Validators.min(0)] ] }) ) ) }); return; } // Walk-in identity is required for cash and rejected outright for an account debit — the server // refuses a walk-in name alongside an account — so the controls are only built when they apply. // Registering them unconditionally and merely hiding them would hold the form invalid on a // required field the clerk cannot see. this.form = this.fb.group( this.isAccountDebit ? { beneficiaryName: [ '', [ Validators.required, Validators.maxLength(120) ] ], payableAtBranch: [ '', [Validators.required] ], payableAtCity: [ '', [Validators.required] ] } : { walkinPurchaserName: [ '', [ Validators.required, Validators.minLength(3) ] ], walkinPurchaserAddress: [ '', [Validators.required] ], walkinPurchaserAadhaar: [ '', [ Validators.required, Validators.pattern(/^[0-9]{12}$/) ] ], beneficiaryName: [ '', [ Validators.required, Validators.maxLength(120) ] ], payableAtBranch: [ '', [Validators.required] ], payableAtCity: [ '', [Validators.required] ] } ); } onClose(): void { this.dialogRef.close(); } onSubmit(): void { if (this.activeForm.invalid) return; // A slip closes with its lines. There is no walk-in branch here: a slip is only ever offered on // a member-account debit, where the purchaser is the account holder already on record. if (this.isSlip) { const lines: DdSlipLineResult[] = this.slipLines.controls.map((c) => { const v = c.value; return { beneficiaryName: (v.beneficiaryName || '').trim().toUpperCase(), payableAtBranch: (v.payableAtBranch || '').trim().toUpperCase(), payableAtCity: (v.payableAtCity || '').trim().toUpperCase(), faceValue: Number(v.faceValue) || 0, chargesAmount: Number(v.chargesAmount) || 0 }; }); this.dialogRef.close({ lines }); return; } const val = this.form.value; const beneficiary = { beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(), payableAtBranch: (val.payableAtBranch || '').trim().toUpperCase(), payableAtCity: (val.payableAtCity || '').trim().toUpperCase() }; // Walk-in fields are left off entirely on an account debit rather than sent empty: the server // rejects a purchaser name arriving alongside an account, and '' would be sent as one. this.dialogRef.close( this.isAccountDebit ? beneficiary : { walkinPurchaserName: (val.walkinPurchaserName || '').trim().toUpperCase(), walkinPurchaserAddress: (val.walkinPurchaserAddress || '').trim().toUpperCase(), walkinPurchaserAadhaar: (val.walkinPurchaserAadhaar || '').trim(), ...beneficiary } ); } }