diff --git a/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts index fff71aee1..0c232ba68 100644 --- a/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts +++ b/src/app/cashier/transaction-entry/cashier-issue-dd-dialog.component.ts @@ -8,7 +8,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +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'; @@ -35,6 +35,35 @@ export interface CashierIssueDdDialogData { 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({ @@ -56,10 +85,20 @@ export interface CashierIssueDdDialogData {
receipt_long
-

{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}

+

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

- Next available DD leaflet auto-selected · - {{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }} + @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' }} + }

@@ -69,41 +108,91 @@ export interface CashierIssueDdDialogData { - -
- + @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 }} +
+ 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 + } + +
-
-
- 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 (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
+ @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 +
- Walk-in Purchaser Name * + Payable At - Branch * - Purchaser name is required - Must be at least 3 charactersPayable Branch is required - 12-Digit Aadhaar Number * + Payable At - City * - Masked to last 4 digits before storage - Aadhaar number is required - Must be exactly 12 numeric digits + Payable City is required
- - - 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 + + } - @@ -395,6 +598,81 @@ export interface CashierIssueDdDialogData { line-height: 1.5; color: #64748b; } + /* One card per draft on a slip — the boundary is what stops a clerk typing line 2's + beneficiary against line 1's amount. */ + .dd-line-card { + border: 1px solid #e2e8f0; + border-left: 3px solid #0369a1; + border-radius: 8px; + padding: 14px 16px 4px; + margin-bottom: 14px; + background: #fcfdff; + } + .dd-line-head { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 12px; + } + .dd-line-badge { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + color: #ffffff; + background: #0369a1; + padding: 3px 9px; + border-radius: 999px; + } + .dd-line-number { + font-family: monospace; + font-size: 12px; + color: #475569; + } + .dd-line-number em { + font-style: normal; + color: #94a3b8; + font-size: 11px; + } + .dd-line-total { + margin-left: auto; + font-size: 12px; + font-weight: 600; + color: #065f46; + background: #ecfdf5; + border: 1px solid #a7f3d0; + padding: 3px 10px; + border-radius: 6px; + } + .dd-preview-note { + display: flex; + gap: 8px; + align-items: flex-start; + margin: 0 0 16px; + padding: 9px 12px; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 6px; + font-size: 12px; + line-height: 1.5; + color: #78350f; + } + .dd-preview-note mat-icon { + color: #b45309; + font-size: 18px; + width: 18px; + height: 18px; + flex-shrink: 0; + } + .dd-actions-total { + margin-right: auto; + font-size: 13px; + color: #475569; + } + .dd-actions-total strong { + color: #065f46; + } ` ] }) @@ -404,13 +682,127 @@ export class CashierIssueDdDialogComponent implements OnInit { public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA); form!: FormGroup; + /** Built only in slip mode; `form` stays untouched so the single-draft path is unchanged. */ + slipForm!: FormGroup; /** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */ get isAccountDebit(): boolean { return this.data.paymentMethod === 'ACCOUNT_DEBIT'; } + /** How many drafts this dialog is capturing — 1 unless the clerk asked for a slip. */ + get lineCount(): number { + const n = Number(this.data.ddCount) || 1; + return n > 1 ? n : 1; + } + + /** Whether this dialog is capturing a multi-DD slip rather than a single draft. */ + get isSlip(): boolean { + return this.lineCount > 1; + } + + /** Whether the debit lands on a GL account rather than a member's — wording only. */ + get isGlFunded(): boolean { + return !!this.data.glFunded; + } + + /** The form the Save button validates against — whichever of the two was built. */ + get activeForm(): FormGroup { + return this.isSlip ? this.slipForm : this.form; + } + + get slipLines(): FormArray { + return this.slipForm.get('lines') as FormArray; + } + + /** The provisional leaflet number shown against a line, if the caller supplied a run of them. */ + previewNumberAt(index: number): string | null { + return this.data.previewDdNumbers?.[index] ?? null; + } + + private lineValueAt(index: number, field: string): number { + return Number(this.slipLines.at(index)?.get(field)?.value) || 0; + } + + /** + * GST on one line's charge. + * + * Rounded per line, matching the server, which computes and stores GST against each draft rather + * than once for the slip. Summing rounded lines and rounding a summed charge can differ by a + * paisa, and it is the per-line figure that ends up on the draft. + */ + lineGstAt(index: number): number { + const charges = this.lineValueAt(index, 'chargesAmount'); + if (charges <= 0) return 0; + return Math.round(charges * (Number(this.data.gstPercentage) || 0)) / 100; + } + + /** What one line adds to the debit — its face value, its charge and the GST on that charge. */ + lineTotalAt(index: number): number { + return this.lineValueAt(index, 'faceValue') + this.lineValueAt(index, 'chargesAmount') + this.lineGstAt(index); + } + + get slipFaceValue(): number { + return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'faceValue'), 0); + } + + get slipCharges(): number { + return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineValueAt(i, 'chargesAmount'), 0); + } + + get slipGst(): number { + return this.slipLines.controls.reduce((sum, _c, i) => sum + this.lineGstAt(i), 0); + } + + /** The figure the account is debited for — rounded up to the whole rupee, as the single flow does. */ + get slipTotal(): number { + return Math.ceil(this.slipFaceValue + this.slipCharges + this.slipGst - 1e-9); + } + ngOnInit(): void { + if (this.isSlip) { + this.slipForm = this.fb.group({ + lines: this.fb.array( + Array.from({ length: this.lineCount }, () => + this.fb.group({ + beneficiaryName: [ + '', + [ + Validators.required, + Validators.maxLength(120) + ] + ], + payableAtBranch: [ + '', + [Validators.required] + ], + payableAtCity: [ + '', + [Validators.required] + ], + // Zero is rejected rather than merely warned about: the server refuses a line with a + // face value at or below zero, and a slip that fails there has already cost the clerk + // every other line they typed. + faceValue: [ + null as + | number + | null, + [ + Validators.required, + Validators.min(0.01) + ] + ], + chargesAmount: [ + 0, + [Validators.min(0)] + ] + }) + ) + ) + }); + return; + } + // Walk-in identity is required for cash and rejected outright for an account debit — the server // refuses a walk-in name alongside an account — so the controls are only built when they apply. // Registering them unconditionally and merely hiding them would hold the form invalid on a @@ -477,7 +869,25 @@ export class CashierIssueDdDialogComponent implements OnInit { } onSubmit(): void { - if (this.form.invalid) return; + if (this.activeForm.invalid) return; + + // A slip closes with its lines. There is no walk-in branch here: a slip is only ever offered on + // a member-account debit, where the purchaser is the account holder already on record. + if (this.isSlip) { + const lines: DdSlipLineResult[] = this.slipLines.controls.map((c) => { + const v = c.value; + return { + beneficiaryName: (v.beneficiaryName || '').trim().toUpperCase(), + payableAtBranch: (v.payableAtBranch || '').trim().toUpperCase(), + payableAtCity: (v.payableAtCity || '').trim().toUpperCase(), + faceValue: Number(v.faceValue) || 0, + chargesAmount: Number(v.chargesAmount) || 0 + }; + }); + this.dialogRef.close({ lines }); + return; + } + const val = this.form.value; const beneficiary = { beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(), diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.html b/src/app/cashier/transaction-entry/transaction-entry.component.html index ee14e4589..586117016 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.html +++ b/src/app/cashier/transaction-entry/transaction-entry.component.html @@ -319,9 +319,13 @@ [value]="row.amount ?? ''" (input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()" (click)="$event.stopPropagation()" - [placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'" - [readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)" - [class.ws-amt-input--locked]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)" + [placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'" + [readonly]=" + isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)) + " + [class.ws-amt-input--locked]=" + isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)) + " /> @if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) { @@ -331,6 +335,19 @@ }}/share } + + @if (isMultiDdRow(row)) { + + {{ + row.ddLines?.length + ? 'Total of ' + row.ddLines?.length + ' drafts' + : 'Set per draft in Fill DD Details' + }} + + } @if (row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer')) {
@@ -410,9 +427,36 @@
DD Charges & GST — Admin Rate {{ ddGstPercentage }}%
+ + @if (canRowBeMultiDd(row)) { +
+ +
+ +
+ @if (isMultiDdRow(row)) { + One debit · {{ ddCountFor(row) }} drafts + } +
+ }
-
+
₹
+ + @if (isMultiDdRow(row)) { + Set per draft + }
@@ -447,7 +498,7 @@
@@ -460,18 +511,27 @@ [disabled]="saving" (click)="openIssueWalkinDd(row); $event.stopPropagation()" [title]=" - isDdAccountDebitRow(row) - ? 'Auto-selects next available leaflet and opens the beneficiary form' - : 'Auto-selects next available leaflet and opens walk-in purchaser form' + isMultiDdRow(row) + ? 'Opens one beneficiary form per draft; DD numbers are allocated on submission' + : isDdAccountDebitRow(row) + ? 'Auto-selects next available leaflet and opens the beneficiary form' + : 'Auto-selects next available leaflet and opens walk-in purchaser form' " > {{ - row.ddDetails ? 'edit_note' : 'post_add' + row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add' }} + glance which rows are ready before pressing Save. A slip names the count + instead: its numbers are not allocated until it is submitted. --> {{ - row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details' + row.ddLines?.length + ? row.ddLines?.length + ' Drafts Captured · Edit Slip' + : row.ddDetails + ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' + : isMultiDdRow(row) + ? 'Fill ' + ddCountFor(row) + ' DD Details' + : 'Fill DD Details' }}
@@ -1694,10 +1754,12 @@ [value]="row.amount ?? ''" (input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()" (click)="$event.stopPropagation()" - [placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'" - [readonly]="!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)" + [placeholder]="isMultiDdRow(row) ? 'Per draft' : row.acTyp === 'SHR' ? '0' : '0.00'" + [readonly]=" + isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)) + " [class.ws-amt-input--locked]=" - !isClerk && !!(activeWithdrawalRequest || activeClosureRequest) + isMultiDdRow(row) || (!isClerk && !!(activeWithdrawalRequest || activeClosureRequest)) " />
@@ -1708,6 +1770,19 @@ }}/share } + + @if (isMultiDdRow(row)) { + + {{ + row.ddLines?.length + ? 'Total of ' + row.ddLines?.length + ' drafts' + : 'Set per draft in Fill DD Details' + }} + + }
@if ( row.acTyp === 'SHR' && (row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer') @@ -1772,9 +1847,38 @@
DD Charges & GST — Admin Rate {{ ddGstPercentage }}%
+ + @if (canRowBeMultiDd(row)) { +
+ +
+ +
+ @if (isMultiDdRow(row)) { + One debit · {{ ddCountFor(row) }} drafts + } +
+ }
-
+
₹
+ + @if (isMultiDdRow(row)) { + Set per draft + }
@@ -1809,7 +1920,7 @@
@@ -1822,13 +1933,28 @@ [disabled]="saving" (click)="openIssueWalkinDd(row); $event.stopPropagation()" [title]=" - isDdAccountDebitRow(row) - ? 'Auto-selects next available leaflet and opens the beneficiary form' - : 'Auto-selects next available leaflet and opens walk-in purchaser form' + isMultiDdRow(row) + ? 'Opens one beneficiary form per draft; DD numbers are allocated on submission' + : isDdAccountDebitRow(row) + ? 'Auto-selects next available leaflet and opens the beneficiary form' + : 'Auto-selects next available leaflet and opens walk-in purchaser form' " > - post_add - Fill DD Details + {{ + row.ddDetails || row.ddLines?.length ? 'edit_note' : 'post_add' + }} + + {{ + row.ddLines?.length + ? row.ddLines?.length + ' Drafts Captured · Edit Slip' + : row.ddDetails + ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' + : isMultiDdRow(row) + ? 'Fill ' + ddCountFor(row) + ' DD Details' + : 'Fill DD Details' + }}
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index a96bf4f00..0c916bfe4 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -91,6 +91,40 @@ interface TxnRow { fundingAccountId?: number; fundingClientId?: number; }; + /** + * How many drafts this one row raises. 1 (or unset) is the ordinary single draft; 2 or more make + * the row a multi-DD slip — one debit funding several drafts, each to its own beneficiary. + * + * Typed by the clerk BEFORE opening the dialog, so the dialog knows how many beneficiary forms to + * present. Only a member-account debit can be a slip (see canRowBeMultiDd()). + */ + ddCount?: number | null; + /** + * The per-draft lines of a multi-DD slip, captured in the dialog and held here until Save — the + * same "fill now, commit on Save" contract as ddDetails above. + * + * Each line carries its OWN face value and charges: a slip's drafts are routinely for different + * amounts to different beneficiaries, and the charge is per draft rather than per slip. That is + * why the row's own AMOUNT and CHARGES fields are locked and derived from these lines while a + * slip is being built — a clerk-typed row total could disagree with the lines, and the account + * would then be debited for a figure the drafts don't add up to. + */ + ddLines?: DdSlipLine[]; + /** The inventory the slip's leaflets are drawn from. The batch endpoint allocates by inventory + * rather than by leaflet, since the numbers are only picked inside its own locked transaction. */ + ddInventoryId?: number; + /** Resolved funding account for a slip, captured alongside the lines for the same reason. */ + ddFundingAccountId?: number; + ddFundingClientId?: number; +} + +/** One draft on a multi-DD slip — its own beneficiary, its own face value, its own charges. */ +interface DdSlipLine { + beneficiaryName: string; + payableAtBranch: string; + payableAtCity: string; + faceValue: number; + chargesAmount: number; } interface Denomination { @@ -2415,19 +2449,159 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { */ entryTypeOptions: { id: number; name: string }[] = []; - /** GST on DD charges only (never on the DD face value), at the admin-configured rate. */ + /** + * Whether this row may raise more than one draft off a single debit. + * + * Any funding source the clerk debits: a member's savings/current account, or a GL account for an + * institutional slip. Cash is the exception the batch endpoint still refuses, and for a real + * reason rather than an arbitrary one — a slip is funded by one payment, and one counted pile of + * notes cannot be apportioned across drafts without the denomination total ceasing to prove what + * was actually taken at the counter. + */ + canRowBeMultiDd(row: TxnRow): boolean { + return this.isDdAccountDebitRow(row); + } + + /** Whether the clerk has asked this row for more than one draft. */ + isMultiDdRow(row: TxnRow): boolean { + return this.canRowBeMultiDd(row) && (Number(row.ddCount) || 1) > 1; + } + + /** How many drafts this row raises — always at least 1. */ + ddCountFor(row: TxnRow): number { + const n = Number(row.ddCount) || 1; + return n > 1 ? n : 1; + } + + /** + * Clamps a typed DD count into range and discards lines that no longer fit. + * + * Lowering the count after the dialog has been filled would otherwise leave lines beyond the new + * count still on the row: invisible to the clerk, but still sent at Save and still debited for. + */ + onDdCountChange(row: TxnRow, value: number): void { + const n = Math.floor(Number(value) || 1); + const previous = this.ddCountFor(row); + row.ddCount = n < 1 ? 1 : n > this.maxDdSlipLines ? this.maxDdSlipLines : n; + if (row.ddLines && row.ddLines.length !== row.ddCount) { + this.clearDdCapture(row); + } + // Moving onto a slip empties the row's own amount and charge, because on a slip they mean + // nothing: each draft carries its own face value and its own charge, entered in the dialog, and + // these two fields become the total of those. Leaving a previously typed figure sitting here + // would read as the slip's total while describing only one draft — which is exactly what showed + // ₹1,012 against four drafts of ₹1,000. + if (row.ddCount > 1 && previous <= 1) { + row.amount = null; + row.ddCharges = null; + } + } + + /** Mirrors MAX_SLIP_LINES on the server, so an over-long slip is refused here with a clear message. */ + readonly maxDdSlipLines = 20; + + /** + * The run of leaflet numbers a slip will probably draw, for display only. + * + * Counts up from the next-available number, preserving its width so 000123 reads as 000124 rather + * than 124. Indicative by nature — the binding allocation happens inside the server's submit + * transaction, and a slip filed after another clerk's gets the numbers following theirs — which is + * why the dialog labels these provisional and the confirmed numbers come back on submission. + * + * A number that is not a plain run of digits (a prefixed or hyphenated series) cannot be counted + * on safely, so nothing is shown rather than something wrong. + */ + private previewDdNumbersFrom(firstDdNumber: string, count: number): string[] | undefined { + const first = (firstDdNumber || '').trim(); + if (!/^\d+$/.test(first)) return undefined; + const width = first.length; + const start = Number(first); + if (!Number.isSafeInteger(start)) return undefined; + return Array.from({ length: count }, (_v, i) => String(start + i).padStart(width, '0')); + } + + /** GST on a single charge figure, at the admin-configured rate. */ + private ddGstOn(charges: number): number { + const c = Number(charges) || 0; + if (c <= 0) return 0; + return Math.round(c * this.ddGstPercentage) / 100; + } + + /** + * GST on DD charges only (never on the DD face value), at the admin-configured rate. + * + * On a slip this is the sum of each line's own GST rather than GST on the summed charges. The two + * can differ by a paisa once rounding is applied per line, and it is the per-line figure that is + * stored against each draft and posted by the server — so the row must show that same total. + */ ddGstAmountFor(row: TxnRow): number { - const charges = Number(row.ddCharges) || 0; - if (charges <= 0) return 0; - return Math.round(charges * this.ddGstPercentage) / 100; + if (row.ddLines?.length) { + return row.ddLines.reduce((sum, l) => sum + this.ddGstOn(l.chargesAmount), 0); + } + return this.ddGstOn(row.ddCharges ?? 0); + } + + /** Face value of the row — the sum of every line once a slip has been captured. */ + ddFaceValueFor(row: TxnRow): number { + if (row.ddLines?.length) { + return row.ddLines.reduce((sum, l) => sum + (Number(l.faceValue) || 0), 0); + } + return Number(row.amount) || 0; + } + + /** Charges on the row — the sum of every line's own charge once a slip has been captured. */ + ddChargesFor(row: TxnRow): number { + if (row.ddLines?.length) { + return row.ddLines.reduce((sum, l) => sum + (Number(l.chargesAmount) || 0), 0); + } + return Number(row.ddCharges) || 0; } /** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */ ddTotalPayableFor(row: TxnRow): number { - const amount = Number(row.amount) || 0; - const charges = Number(row.ddCharges) || 0; - const gst = this.ddGstAmountFor(row); - return Math.ceil(amount + charges + gst - 1e-9); + return Math.ceil(this.ddFaceValueFor(row) + this.ddChargesFor(row) + this.ddGstAmountFor(row) - 1e-9); + } + + /** + * What the TOTAL field shows — a figure, or a dash while a slip has nothing to total yet. + * + * A slip whose drafts have not been entered has no total: its face values and charges live on the + * lines, and there are no lines. Showing ₹0 there would read as a real total of zero rather than + * as "not yet known", so the field says so instead. + */ + ddTotalDisplayFor(row: TxnRow): string { + if (this.isSlipAwaitingDetails(row)) { + return '—'; + } + return String(this.ddTotalPayableFor(row)); + } + + /** GST field's text, dashed for the same reason as the total while a slip is still empty. */ + ddGstDisplayFor(row: TxnRow): string { + if (this.isSlipAwaitingDetails(row)) { + return '—'; + } + return this.ddGstAmountFor(row).toFixed(2); + } + + /** A slip the clerk has asked for but not yet filled in — nothing to total from. */ + isSlipAwaitingDetails(row: TxnRow): boolean { + return this.isMultiDdRow(row) && !row.ddLines?.length; + } + + /** + * Forgets a row's captured draft details, leaving the row itself intact. + * + * Both shapes are cleared together on purpose: a row carrying a stale ddDetails alongside fresh + * ddLines (or the reverse) would be read by whichever of the two Save paths it reached first, and + * the clerk would have no way of seeing which one went. + */ + private clearDdCapture(row: TxnRow): void { + row.ddDetails = undefined; + row.ddLines = undefined; + row.ddInventoryId = undefined; + row.ddFundingAccountId = undefined; + row.ddFundingClientId = undefined; } get isSelectedRowDdCashGl(): boolean { @@ -2468,11 +2642,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { ]); return; } + // On a slip the face value lives on each line, typed in the dialog that is about to open — the + // row's own AMOUNT is the sum of those lines and cannot exist yet. Demanding it here would make + // a slip impossible to start. + const isSlip = this.isMultiDdRow(row); const faceValue = Number(row.amount); - if (!faceValue || faceValue <= 0) { + if (!isSlip && (!faceValue || faceValue <= 0)) { this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']); return; } + if (isSlip && !this.isDdAccountDebitRow(row)) { + this.showAlert('error', 'Multiple DDs Not Available', [ + 'More than one draft can only be raised against a member savings or current account.', + 'Set NO. OF DD back to 1 for this row.' + ]); + return; + } const chargesAmount = Number(row.ddCharges) || 0; const gstAmount = this.ddGstAmountFor(row); const totalCashPayable = this.ddTotalPayableFor(row); @@ -2508,8 +2693,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } // Only a cash draft has notes to reconcile. An account debit takes no cash at the counter, so a - // denomination count left over from an earlier row must not be checked against it. - if (!accountDebit && this.denominationTotal > 0 && Math.abs(this.denominationTotal - totalCashPayable) > 0.005) { + // denomination count left over from an earlier row must not be checked against it. A slip is + // always an account debit, and its total is not known until the dialog is filled in either. + if ( + !accountDebit && + !isSlip && + this.denominationTotal > 0 && + Math.abs(this.denominationTotal - totalCashPayable) > 0.005 + ) { this.showAlert('error', 'Denomination Mismatch', [ `Cash denomination counted (₹${this.denominationTotal}) does not match total payable cash (₹${totalCashPayable}).`, 'Please verify and match the physical cash denomination count.' @@ -2532,8 +2723,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { const selectedBank = this.sponsorBanks.find((b: any) => b.id === row.sponsorBankId); const branchName = this.branchNameFor(row.br) || 'Branch ' + row.br; + // A slip is allocated by inventory, not leaflet: the server picks the whole run inside its + // own locked transaction. Without the inventory behind this branch's next-available leaflet + // there is nothing to allocate from, so the slip is refused here rather than at submit. + if (isSlip && !leaflet.inventoryId) { + this.showAlert('error', 'DD Inventory Not Identified', [ + 'The DD inventory behind this branch and sponsor bank could not be identified, so multiple drafts cannot be allocated together.', + 'Set NO. OF DD back to 1, or ask an administrator to check the DD inventory registration.' + ]); + return; + } + const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, { - width: '600px', + width: isSlip ? '760px' : '600px', disableClose: true, data: { leaflet, @@ -2546,7 +2748,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { totalCashPayable, paymentMethod: accountDebit ? ('ACCOUNT_DEBIT' as const) : ('CASH' as const), accountNo: accountDebit ? row.acNo : undefined, - accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined + accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined, + ddCount: isSlip ? this.ddCountFor(row) : undefined, + glFunded: isGlDebit, + previewDdNumbers: isSlip ? this.previewDdNumbersFrom(leaflet.ddNumber, this.ddCountFor(row)) : undefined } }); @@ -2555,6 +2760,31 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // review the entered details, and no way to abandon them either. dialogRef.afterClosed().subscribe((result) => { if (!result) return; + + // A slip records its lines instead of a single beneficiary, and takes the row's AMOUNT and + // CHARGES with it: from here on those two are the sum of the lines, shown read-only, so a + // clerk cannot leave a row total behind that disagrees with what the drafts add up to. + if (result.lines) { + this.clearDdCapture(row); + row.ddLines = result.lines; + row.ddInventoryId = leaflet.inventoryId; + row.ddFundingAccountId = fundingAccountId!; + // Null on a GL slip, which is raised against no member at all. + row.ddFundingClientId = fundingClientId ?? undefined; + row.amount = this.ddFaceValueFor(row); + row.ddCharges = this.ddChargesFor(row); + this.showAlert('success', 'Slip Details Captured', [ + `${result.lines.length} demand drafts captured in favour of ${result.lines + .map((l: DdSlipLine) => l.beneficiaryName) + .join(', ')}.`, + `₹${this.ddTotalPayableFor(row)} will be debited from ${isGlDebit ? 'GL account ' : ''}${row.acNo} as a single ${ + isGlDebit ? 'journal entry' : 'transaction' + } once a checker approves.`, + 'DD numbers are allocated on submission. Press Save to send this slip for approval.' + ]); + return; + } + row.ddDetails = { leafletId: leaflet.id, ddNumber: leaflet.ddNumber, @@ -2658,7 +2888,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { * got none has no way of telling which row was missing. */ private saveDdRows(ddRows: TxnRow[]): void { - const unfilled = ddRows.filter((r) => !r.ddDetails); + const unfilled = ddRows.filter((r) => !r.ddDetails && !r.ddLines?.length); if (unfilled.length > 0) { this.showAlert('error', 'DD Details Not Entered', [ unfilled.length === 1 @@ -2670,6 +2900,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } for (const row of ddRows) { + // A slip goes to the batch endpoint, which allocates its leaflets and posts one debit for the + // whole run. Sending its lines one at a time through the single-DD endpoint would debit the + // member once per draft — the very thing a slip exists to avoid. + if (row.ddLines?.length) { + this.submitDdSlip(row); + continue; + } + const details = row.ddDetails!; const leaflet = { id: details.leafletId, ddNumber: details.ddNumber }; if (this.isDdAccountDebitRow(row)) { @@ -2697,6 +2935,99 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } } + /** + * Submits a multi-DD slip: several drafts raised together against one debit of the member's + * account. + * + * Maker-checker like the single-draft flow, and nothing is debited here. On approval the server + * withdraws the combined face value, charges and GST of every line as ONE transaction and posts + * one journal entry against it, then issues each draft — so the member's passbook carries a single + * entry naming all the DD numbers rather than one entry per draft. The checker approves or rejects + * the slip whole; there is no approving four drafts out of five. + * + * The leaflets are not chosen here. The server allocates the run inside its own locked transaction + * and returns which number went to which line, which is the only point at which the numbers are + * real — two clerks submitting at the same moment can therefore never be handed the same one. + */ + private submitDdSlip(row: TxnRow): void { + const lines = row.ddLines ?? []; + const inventoryId = row.ddInventoryId; + const fundingAccountId = row.ddFundingAccountId; + const fundingClientId = row.ddFundingClientId; + + const isGl = row.acTyp === 'GL'; + + // Re-checked at Save, not just when the details were captured: the row's TYPE, AC.TP or account + // number can all be edited afterwards, and a slip that has drifted onto a cash row is one the + // batch endpoint refuses outright. A GL slip is raised against no member, so it carries no + // client id and none is required of it — the same asymmetry as the single-draft GL path. + if (!inventoryId || !fundingAccountId || (!isGl && !fundingClientId) || !this.canRowBeMultiDd(row)) { + this.showAlert('error', 'Slip Cannot Be Submitted', [ + 'This row has changed since its drafts were captured, so the slip no longer matches it.', + 'Open "Fill DD Details" again and re-enter the drafts.' + ]); + return; + } + + const d = new Date(); + const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB'; + + // The funding source goes in exactly one of savingsAccountId, currentAccountId or glAccountId, + // as the single-draft path does: which one is set is what decides the debit leg on approval. + const payload = { + clientId: isGl ? undefined : fundingClientId, + issuingBranchId: row.br, + issuedDate, + paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT', + savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId, + currentAccountId: !isGl && isCurrent ? fundingAccountId : undefined, + glAccountId: isGl ? fundingAccountId : undefined, + lines: lines.map((l) => ({ + beneficiaryName: l.beneficiaryName, + payableAtBranch: l.payableAtBranch, + payableAtCity: l.payableAtCity, + faceValue: Number(l.faceValue), + chargesAmount: Number(l.chargesAmount) || 0 + })) + }; + + const totalDebited = this.ddTotalPayableFor(row); + const accountNo = row.acNo; + + this.saving = true; + this.sponsorDDService.submitIssueRequestBatch(inventoryId, payload).subscribe({ + next: (result: any) => { + this.saving = false; + // The allocated numbers, named back to the clerk. Until this response they were provisional, + // and these are what the customer's drafts will actually carry. + const allocated = (result?.lines ?? []) + .map((l: any) => `${l.ddNumber} · ${l.beneficiaryName} · ₹${l.faceValue}`) + .join('\n'); + this.showAlert('success', 'DD Slip Submitted', [ + `Slip of ${lines.length} demand drafts submitted successfully.`, + allocated || `${lines.length} drafts allocated.`, + `₹${totalDebited} will be debited from ${accountNo} as a single transaction on approval.`, + 'Sent to Manager / Head Cashier for maker-checker approval of the whole slip.' + ]); + row.amount = null; + row.ddCharges = null; + row.description = ''; + row.ddCount = 1; + // Cleared with the row, as the single-draft paths do: leaving the lines behind would let a + // second Save raise the same slip again against leaflets already allocated. + this.clearDdCapture(row); + }, + error: (err) => { + this.saving = false; + this.showAlert('error', 'DD Slip Submission Failed', [ + err.error?.message || err.message, + 'No drafts were issued and nothing has been debited — the whole slip was refused together.' + ]); + } + }); + } + private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void { // Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked // right but matched nothing, so every cash DD went in with no breakdown at all and its money diff --git a/src/app/fiu/fiu.component.ts b/src/app/fiu/fiu.component.ts index 245162f51..e918bce81 100644 --- a/src/app/fiu/fiu.component.ts +++ b/src/app/fiu/fiu.component.ts @@ -982,9 +982,16 @@ export class FiuComponent implements OnInit { get liveStrFiled(): number { return this.amlStrCases.filter((c) => c.status === 'FILED').length; } - /** Still needs PO action: untouched, or concluded suspicious but not yet filed with FIU-IND. */ + /** + * Still needs PO action: anything neither filed with FIU-IND nor concluded clear. + * + * Counted by what is unfinished rather than by naming the statuses that are. Adding up OPEN and CONCLUDED_SUSPICIOUS + * gives the same figure today, but it is the shape that fails quietly: a case in any status those two do not name + * drops out of the KPI while still sitting in the queue below it, and the dashboard then under-reports the work. + * That is exactly how five undecided alerts hid behind a pending count of 1 on the flagged-transactions screen. + */ get liveStrPending(): number { - return this.liveStrOpen + this.liveStrAwaitingFiling; + return this.amlStrCases.filter((c) => c?.status !== 'FILED' && c?.status !== 'CONCLUDED_CLEAR').length; } get totalCustomers(): number { return this.kc1.length + this.kc2.length; diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html index daccfbafa..945e5f5a8 100644 --- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html +++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.html @@ -21,14 +21,31 @@
+
- +
-

Dividend declared for {{ result.financialYear }}

+

+ @if (hasSkipped) { + Declared for {{ result.financialYear }} — {{ result.skipped.length }} + {{ result.skipped.length === 1 ? 'product was' : 'products were' }} skipped + } @else { + Dividend declared for {{ result.financialYear }} + } +

{{ result.declarationsCreated }} {{ result.declarationsCreated === 1 ? 'declaration' : 'declarations' }} created. Each still needs approval before members can claim. + @if (hasSkipped) { + Members of the skipped {{ result.skipped.length === 1 ? 'product' : 'products' }} below will receive + nothing for this year — check the reason and declare separately if that is not intended. + }

@@ -128,7 +145,22 @@ − {{ preview.totalAmount | number: '1.2-2' }}
- @if (isUnderfunded) { + @if (exceedsMaximumRate) { + +
+ {{ declareForm.value.ratePercentage }}% is above the society's maximum of + {{ preview.maximumRatePercentage }}%. Lower the rate, or raise the limit in Configurations if the board + has resolved a higher one. +
+ } @else if (isPeriodNotFinished) { + +
+ {{ preview.financialYear }} has not finished yet, so its profit is not final. A dividend can only be + declared once the financial year is over. +
+ } @else if (isUnderfunded) {
Short by {{ preview.fundingShortfall | number: '1.2-2' }}. There is not enough in {{ preview.profitGlCode }} to fund this dividend — lower the rate, or close the year's profit first. @@ -146,16 +178,42 @@ {{ declareForm.value.ratePercentage || 0 }}% on each member’s share investment
+ @for (product of eligibleProducts; track product.productId) {
- {{ product.productName }} + + {{ product.productName }} + + {{ product.memberCount }} {{ product.memberCount === 1 ? 'member' : 'members' }} + @if (product.accountCount !== product.memberCount) { + · {{ product.accountCount }} accounts + } + + {{ product.amount | number: '1.2-2' }}
} @if (eligibleProducts.length > 0) {
- Total to declare + + Total to declare + @if (preview.totalMemberCount) { + + + {{ preview.totalMemberCount }} + {{ preview.totalMemberCount === 1 ? 'member' : 'members' }} + @if (preview.totalAccountCount !== preview.totalMemberCount) { + · {{ preview.totalAccountCount }} accounts + } + + } + {{ payableTotal | number: '1.2-2' }}
} diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss index 334f70aa4..c95fb17a2 100644 --- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss +++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.scss @@ -148,6 +148,14 @@ form { text-align: right; } +// Who a product's amount reaches, set quietly beside the product name so it reads as a +// qualifier on the amount rather than competing with it. +.dd-members { + margin-left: 8px; + font-size: 12px; + color: #94a3b8; +} + .dd-note { margin-top: 12px; padding: 10px 14px; @@ -194,6 +202,11 @@ form { font-size: 26px; color: #16a34a; margin-top: 2px; + + // A run that left a product out is amber, not green: it succeeded, but not for everyone. + &--warn { + color: #d97706; + } } .dd-table { diff --git a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts index 83f7d52e6..5ab5cbd9c 100644 --- a/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts +++ b/src/app/products/share-products/all-products-dividends/declare-dividend/declare-dividend.component.ts @@ -73,6 +73,17 @@ export class DeclareDividendComponent implements OnInit { /** Set once the declaration has gone through, so the outcome per product can be shown. */ result: any = null; + /** + * Whether any product was left out of the declaration that just ran. + * + * Drives the outcome heading. A run that skipped a product still returns success — one product + * being ineligible is not a reason to lose the others — but those members receive nothing for the + * year, so the screen has to say so rather than report a plain "declared". + */ + get hasSkipped(): boolean { + return (this.result?.skipped?.length ?? 0) > 0; + } + ngOnInit(): void { this.declareForm = this.formBuilder.group({ financialYear: [ @@ -96,9 +107,13 @@ export class DeclareDividendComponent implements OnInit { /** * The financial years a dividend can be declared for: the current one and the five before it. * - * The current year is included because a society may declare an interim dividend part-way through, - * and five back covers a declaration delayed by an AGM that ran late. Built from the business date - * rather than the browser's clock, so the list matches the year the branch is operating in. + * The year in progress is listed but cannot be declared — the server rejects it, and the screen + * says so as soon as it is picked. It stays on the list so an officer looking for it finds it with + * the reason attached, rather than wondering why the year they are in is missing. Five back covers + * a declaration delayed by an AGM that ran late. + * + * Built from the business date rather than the browser's clock, so the list matches the year the + * branch is operating in. */ private buildFinancialYears(): void { const businessDate: Date = this.settingsService.businessDate ?? new Date(); @@ -171,8 +186,36 @@ export class DeclareDividendComponent implements OnInit { return !!this.preview?.exceedsDistributableProfit; } + /** + * Whether the chosen year is still running. + * + * A dividend is a share of the year's profit, so it cannot be declared until the year is over and + * that profit is final. The server enforces this; the flag simply lets the screen say so before the + * officer commits to a rate. + */ + get isPeriodNotFinished(): boolean { + return !!this.preview?.periodNotFinished; + } + + /** + * Whether the entered rate is above the society's configured maximum. + * + * The bye-laws cap what may be paid in a year, so a rate above it needs a board resolution rather + * than a typed figure. The server enforces the same limit. + */ + get exceedsMaximumRate(): boolean { + return !!this.preview?.exceedsMaximumRate; + } + get canSubmit(): boolean { - return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0 && !this.isUnderfunded; + return ( + this.declareForm.valid && + !this.submitting && + this.eligibleProducts.length > 0 && + !this.isUnderfunded && + !this.isPeriodNotFinished && + !this.exceedsMaximumRate + ); } submit(): void { diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts index 6f5b070ea..85b49edf4 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts @@ -767,7 +767,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O // The day narrows everything else: the status chips and the severity tiles are asking "of what is in view", and // an officer who has picked a day expects their counts to describe that day rather than the whole feed. const byDay = this.allAlerts.filter((a) => this.inActivePeriod(a)); - const byStatus = this.activeStatus ? byDay.filter((a) => a.status === this.activeStatus) : byDay; + const byStatus = this.activeStatus ? byDay.filter((a) => this.matchesStatus(a, this.activeStatus)) : byDay; const bySeverity = this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity); // Ranked last, over what the other filters left: "top 10 this month" is the ten busiest rules of that month, not @@ -914,7 +914,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O get summaryTotal(): number { const pool = this.allAlerts.filter((a) => this.inActivePeriod(a)); - return this.activeStatus ? pool.filter((a) => a.status === this.activeStatus).length : pool.length; + return this.activeStatus ? pool.filter((a) => this.matchesStatus(a, this.activeStatus)).length : pool.length; } private recomputeStatusCounts(): void { @@ -924,11 +924,30 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O this.statusCounts = { '': pool.length }; for (const s of this.statusFilters) { if (s.key) { - this.statusCounts[s.key] = pool.filter((a) => a.status === s.key).length; + this.statusCounts[s.key] = pool.filter((a) => this.matchesStatus(a, s.key)).length; } } } + /** + * Whether an alert belongs under a status chip. + * + * Not an equality test, deliberately. Matching the chip's key exactly meant any status the chip list did not name + * belonged to no chip at all while still counting toward "All alerts" — so an alert could sit in the feed, need a + * decision, and appear nowhere an officer looks for work. That is what UNDER_REVIEW did here: five undecided + * alerts, the largest ₹18,00,000, behind a pending figure that read 1. + * + * Pending is therefore defined by what has not been decided rather than by one status name. Any status that is + * neither whitelisted nor escalated surfaces under Raised on its own, which is the safe direction to fail — an + * alert shown as work that turns out to be finished costs a glance; one hidden costs a filing. + */ + private matchesStatus(alert: any, key: string): boolean { + if (key === 'RAISED') { + return alert?.status !== 'WHITELISTED' && alert?.status !== 'ESCALATED'; + } + return alert?.status === key; + } + statusClass(status: string): string { return 'alert-status status-' + (status || 'raised').toLowerCase(); } diff --git a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.html b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.html index 49ee7aed2..15eb71f78 100644 --- a/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.html +++ b/src/app/sponsor-dd/dd-issue-approvals/dd-issue-approvals.component.html @@ -45,9 +45,28 @@ DD No. - {{ element.ddNumber }} -
- Slip · {{ batchSiblingCount(element) }} drafts + +
+ +
+ {{ element.ddNumber }} +
+ Slip #{{ element.batchId }} · draft {{ batchPositionOf(element) }} of + {{ batchSiblingCount(element) }} +
+
@@ -64,7 +83,19 @@ Face Value - {{ element.faceValue | number: '1.2-2' }} + + {{ element.faceValue | number: '1.2-2' }} + +
+ Slip debits ₹{{ batchTotalDebit(element) | number: '1.2-2' }} +
+
@@ -107,19 +138,34 @@ Actions -
+ +
- Approving/rejecting applies to all {{ batchSiblingCount(element) }} drafts in this slip. + Covers all {{ batchSiblingCount(element) }} drafts. You can leave out any the customer no longer wants. +
+
+ ↑ handled with Slip #{{ element.batchId }}
{ - this.dataSource.data = data; + // Grouped here rather than in the template: a slip's drafts have to be adjacent rows for the + // shared colour bar and the "n of m" position to read as one block. + this.dataSource.data = this.groupBySlip(data || []); this.applyFilter(); }, error: (err) => { @@ -235,6 +272,78 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { return this.batchSiblings(request).length; } + /** + * Whether this row is the one that carries its slip's buttons — its first draft. + * + * A single (non-slip) request is always its own lead, so the ordinary case is untouched. + */ + isBatchLead(request: any): boolean { + return !request.batchId || this.batchPositionOf(request) === 1; + } + + /** + * Which draft of its slip this row is — "2 of 3". + * + * A slip's drafts are ordered by the request id they were created with, which is the order the + * clerk entered them in and the order the server allocated leaflets in. Without this an approver + * looking at three rows of one slip cannot tell whether they are seeing all of it. + */ + batchPositionOf(request: any): number { + if (!request.batchId) { + return 1; + } + const ordered = this.batchSiblings(request).sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0)); + return ordered.findIndex((r) => r.id === request.id) + 1; + } + + /** What the whole slip debits: every draft's face value, charges and GST together. */ + batchTotalDebit(request: any): number { + return this.batchSiblings(request).reduce( + (sum, r) => sum + (Number(r.faceValue) || 0) + (Number(r.chargesAmount) || 0) + (Number(r.gstAmount) || 0), + 0 + ); + } + + /** + * A stable colour per slip, so two customers' slips sitting next to each other are told apart at a + * glance rather than by reading every batch number. + * + * Keyed off the batch id itself so the same slip keeps its colour across reloads and across the + * PENDING/APPROVED/ALL tabs — a colour that reshuffled on every refresh would be worse than none. + */ + batchColour(request: any): string { + if (!request.batchId) { + return 'transparent'; + } + const palette = [ + '#7c3aed', + '#0891b2', + '#c2410c', + '#15803d', + '#be123c', + '#4338ca' + ]; + return palette[Number(request.batchId) % palette.length]; + } + + /** + * Orders the table so a slip's drafts sit together, newest slip first. + * + * The list arrives in whatever order the server returns, which scattered one slip's drafts among + * another's — an approver then had no way of seeing which rows belonged together, and the "Slip · + * 3 drafts" badge only said that some three rows somewhere did. + */ + private groupBySlip(rows: any[]): any[] { + return [...rows].sort((a, b) => { + const groupA = Number(a.batchId ?? a.id) || 0; + const groupB = Number(b.batchId ?? b.id) || 0; + if (groupA !== groupB) { + return groupB - groupA; + } + return (Number(a.id) || 0) - (Number(b.id) || 0); + }); + } + approve(request: any): void { if (request.batchId) { this.approveBatch(request); @@ -287,29 +396,51 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { } private approveBatch(request: any): void { - const siblings = this.batchSiblings(request); + // Ordered by request id so the checkbox list matches the "draft n of m" the table shows. + const siblings = this.batchSiblings(request).sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0)); const total = siblings.reduce((sum, r) => sum + (Number(r.faceValue) || 0), 0); this.dialog .open(ApproveIssueRequestDialogComponent, { - width: '460px', + width: '520px', maxWidth: '95vw', data: { isBatch: true, clientName: request.clientName, totalFaceValue: total, - lines: siblings.map((r) => ({ beneficiaryName: r.beneficiaryName, faceValue: Number(r.faceValue) || 0 })), + // id, charges and GST travel with each line: the dialog debits by what is ticked, and + // names the rows back to the server by id rather than by position. + lines: siblings.map((r) => ({ + id: r.id, + ddNumber: r.ddNumber, + beneficiaryName: r.beneficiaryName, + faceValue: Number(r.faceValue) || 0, + chargesAmount: Number(r.chargesAmount) || 0, + gstAmount: Number(r.gstAmount) || 0 + })), effect: this.effectTextFor(request, siblings.length) } }) .afterClosed() - .subscribe((confirmed) => { - if (!confirmed) { + .subscribe((result) => { + if (!result) { return; } - this.sponsorDDService.approveIssueRequestBatch(request.batchId).subscribe({ + // true means the whole slip, so no selection is sent and the server takes its ordinary path. + const approvedRequestIds = result === true ? undefined : result.approvedRequestIds; + const issuing = approvedRequestIds ? approvedRequestIds.length : siblings.length; + const dropped = siblings.length - issuing; + this.sponsorDDService.approveIssueRequestBatch(request.batchId, approvedRequestIds).subscribe({ next: () => { - this.snackBar.open('DD slip approved and posted successfully', 'Close', { duration: 3000 }); + this.snackBar.open( + dropped > 0 + ? `${issuing} draft${issuing === 1 ? '' : 's'} issued · ${dropped} not required, DD number${ + dropped === 1 ? '' : 's' + } returned to stock` + : 'DD slip approved and posted successfully', + 'Close', + { duration: dropped > 0 ? 6000 : 3000 } + ); this.loadRequests(); }, error: (err) => { @@ -380,11 +511,35 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
- -
    -
  • {{ l.beneficiaryName }} — ₹{{ l.faceValue | number: '1.2-2' }}
  • -
+ +
+ +
+ + +
+
+ {{ selectedCount }} of {{ data.lines.length }} drafts selected + ₹{{ selectedDebit | number: '1.2-2' }} +
+

+ {{ droppedCount }} draft{{ droppedCount === 1 ? '' : 's' }} will not be issued. Their DD numbers return to + stock for the next customer, and nothing is debited for them. +

+

{{ data.effect }} @@ -392,8 +547,18 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { - `, @@ -433,11 +598,69 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { } .approve-lines { margin: 0 0 12px; - padding-left: 20px; font-size: 12.5px; color: #334155; - max-height: 160px; + max-height: 200px; overflow-y: auto; + border: 1px solid #e2e8f0; + border-radius: 8px; + } + /* Whole row is the hit target — a checkbox alone is a small thing to aim at when the decision + it carries is whether a customer's draft gets issued. */ + .approve-line { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-bottom: 1px solid #f1f5f9; + cursor: pointer; + } + .approve-line:last-child { + border-bottom: none; + } + .approve-line:hover { + background: #f8fafc; + } + .approve-line input { + cursor: pointer; + width: 16px; + height: 16px; + flex-shrink: 0; + } + .approve-line__dd { + font-family: monospace; + color: #0369a1; + flex-shrink: 0; + } + .approve-line__name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .approve-line__amt { + font-weight: 600; + flex-shrink: 0; + } + /* A draft that has been unticked is still listed, greyed, so the approver can see what they + are leaving out rather than watching it disappear. */ + .approve-line input:not(:checked) ~ span { + opacity: 0.45; + text-decoration: line-through; + } + .approve-selection { + border: 1px solid #bfdbfe; + background: #eff6ff; + border-radius: 8px; + padding: 2px 14px; + margin-bottom: 12px; + } + .approve-dropped { + margin: 0 0 10px; + font-size: 11.5px; + line-height: 1.5; + color: #92400e; } .approve-effect { margin: 0; @@ -455,6 +678,75 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit { export class ApproveIssueRequestDialogComponent { dialogRef = inject(MatDialogRef); data = inject(MAT_DIALOG_DATA); + + /** + * Which of the slip's drafts are still wanted, by line index. + * + * Every draft starts ticked: the clerk submitted the slip for a customer who asked for all of it, + * so approving the whole thing has to stay the single-click case. Dropping one is the exception + * and costs one tick. + */ + selected = new Set((this.data.lines || []).map((_l: any, i: number) => i)); + + isSelected(index: number): boolean { + return this.selected.has(index); + } + + toggle(index: number): void { + if (this.selected.has(index)) { + this.selected.delete(index); + } else { + this.selected.add(index); + } + } + + get selectedCount(): number { + return this.selected.size; + } + + get droppedCount(): number { + return (this.data.lines?.length || 0) - this.selected.size; + } + + /** What one draft costs the account: its face value, its own charge, and the GST on that charge. */ + lineDebitAt(index: number): number { + const line = this.data.lines?.[index]; + if (!line) { + return 0; + } + return (Number(line.faceValue) || 0) + (Number(line.chargesAmount) || 0) + (Number(line.gstAmount) || 0); + } + + /** The debit as it currently stands — only the ticked drafts. */ + get selectedDebit(): number { + return [...this.selected].reduce((sum, i) => sum + this.lineDebitAt(i), 0); + } + + /** + * Closes with the request ids to issue, or true for "all of them". + * + * True rather than the full id list when nothing was dropped, so the caller sends no selection at + * all and the server takes its ordinary whole-slip path. + */ + confirm(): void { + if (!this.data.isBatch) { + this.dialogRef.close(true); + return; + } + if (this.selectedCount === 0) { + return; + } + if (this.droppedCount === 0) { + this.dialogRef.close(true); + return; + } + this.dialogRef.close({ + approvedRequestIds: [...this.selected] + .sort((a, b) => a - b) + .map((i) => this.data.lines[i].id) + .filter((id: any) => id != null) + }); + } } // ── REJECT DIALOG COMPONENT ── 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 b4516b3c4..d2f1315f5 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 @@ -15,6 +15,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { SponsorDDService } from '../sponsor-dd.service'; @@ -30,6 +31,7 @@ import { SponsorDDService } from '../sponsor-dd.service'; MatInputModule, MatSelectModule, MatButtonModule, + MatIconModule, MatSnackBarModule ], template: ` @@ -41,15 +43,25 @@ import { SponsorDDService } from '../sponsor-dd.service';

- - - Sponsor DD Payable GL Account * - - {{ acc.glCode }} · {{ acc.name }} - - Credited for the DD Face Value - Required - + +
+ account_balance +
+ DD Payable is now set per sponsor bank. +
+ Each bank credits its own payable account, so what is owed to each can be settled separately. Set it in + Organization > Sponsor Banks. +
+
+
@@ -131,6 +143,29 @@ import { SponsorDDService } from '../sponsor-dd.service'; gap: 12px; padding-top: 8px; } + .payable-moved-note { + display: flex; + gap: 10px; + align-items: flex-start; + margin-bottom: 18px; + padding: 11px 14px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-left: 3px solid #1d4ed8; + border-radius: 6px; + font-size: 13px; + color: #334155; + } + .payable-moved-note mat-icon { + color: #1d4ed8; + flex-shrink: 0; + } + .payable-moved-sub { + margin-top: 3px; + font-size: 11.5px; + line-height: 1.5; + color: #64748b; + } .full-width { width: 100%; } @@ -170,10 +205,10 @@ export class GlSettingsDialogComponent implements OnInit { ngOnInit(): void { this.form = this.fb.group({ - ddPayableGlAccountId: [ - null, - [Validators.required] - ], + // No longer edited here — it lives on the sponsor bank. Kept in the form, unvalidated, so + // saving this screen round-trips the stored value instead of clearing it: existing banks were + // seeded from it on upgrade, and nothing should quietly erase what they were seeded from. + ddPayableGlAccountId: [null], chargesIncomeGlAccountId: [ null, [Validators.required] diff --git a/src/app/sponsor-dd/sponsor-bank-list/sponsor-bank-list.component.ts b/src/app/sponsor-dd/sponsor-bank-list/sponsor-bank-list.component.ts index 3138111e7..5a55ebe6c 100644 --- a/src/app/sponsor-dd/sponsor-bank-list/sponsor-bank-list.component.ts +++ b/src/app/sponsor-dd/sponsor-bank-list/sponsor-bank-list.component.ts @@ -14,9 +14,11 @@ import { HttpClient } from '@angular/common/http'; import { MatCardModule } from '@angular/material/card'; import { MatTableModule } from '@angular/material/table'; import { MatButtonModule } from '@angular/material/button'; -import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +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 { MatSelectModule } from '@angular/material/select'; +import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; @Component({ @@ -29,6 +31,7 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; MatTableModule, MatButtonModule, MatDialogModule, + MatIconModule, MatSnackBarModule ], template: ` @@ -75,6 +78,27 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; {{ element.swiftCode || '-' }} + + + DD Payable GL + + + + warning Not set — drafts cannot be issued + + + + + + + + + + + @@ -87,9 +111,27 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; ` .sponsor-bank-container { padding: 24px; - max-width: 1000px; + max-width: 1280px; margin: 0 auto; } + .gl-account { + font-size: 12.5px; + color: #0f766e; + } + /* Loud on purpose: a bank in this state silently blocks every draft drawn on it. */ + .gl-missing { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: #b45309; + font-weight: 600; + } + .gl-missing mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } .header-row { display: flex; justify-content: space-between; @@ -143,7 +185,9 @@ export class SponsorBankListComponent implements OnInit { 'description', 'ifscCode', 'bankCode', - 'swiftCode' + 'swiftCode', + 'ddPayable', + 'actions' ]; dataSource: any[] = []; @@ -163,9 +207,18 @@ export class SponsorBankListComponent implements OnInit { } openAddDialog(): void { + this.openBankDialog(null); + } + + openEditDialog(bank: any): void { + this.openBankDialog(bank); + } + + private openBankDialog(bank: any): void { const dialogRef = this.dialog.open(AddSponsorBankDialogComponent, { - width: '450px', - disableClose: true + width: '480px', + disableClose: true, + data: { bank } }); dialogRef.afterClosed().subscribe((result) => { @@ -187,10 +240,11 @@ export class SponsorBankListComponent implements OnInit { MatDialogModule, MatFormFieldModule, MatInputModule, + MatSelectModule, MatButtonModule ], template: ` -

Add Sponsor Bank

+

{{ isEdit ? 'Edit Sponsor Bank' : 'Add Sponsor Bank' }}

@@ -236,6 +290,29 @@ export class SponsorBankListComponent implements OnInit { />
+ + +
Accounting
+ + DD Payable GL Account + + + {{ acc.glCode }} · {{ acc.name }} + + + Credited for the face value of every draft drawn on this bank + + A DD Payable account is required — drafts on this bank cannot be issued without one + + @@ -261,6 +338,16 @@ export class SponsorBankListComponent implements OnInit { .half-width { flex: 1; } + .gl-section-label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: #475569; + margin: 4px 0 12px; + padding-top: 12px; + border-top: 1px solid #e2e8f0; + } ` ] }) @@ -270,22 +357,53 @@ export class AddSponsorBankDialogComponent implements OnInit { private dialogRef = inject(MatDialogRef); private snackBar = inject(MatSnackBar); + /** The bank being edited, or null when adding a new one. */ + data = inject(MAT_DIALOG_DATA, { optional: true }) as any; + form!: FormGroup; loading = false; + /** + * Liability accounts only, and only postable, enabled ones. + * + * A DD payable is money owed to the sponsor until the draft is presented, so offering asset or + * income accounts here would only invite a choice the server then refuses. + */ + liabilityGlAccounts: any[] = []; + + get isEdit(): boolean { + return !!this.data?.bank?.id; + } ngOnInit(): void { + const bank = this.data?.bank; this.form = this.fb.group({ name: [ - '', + bank?.name ?? '', Validators.required ], - description: ['Sponsor Bank'], + description: [bank?.description ?? 'Sponsor Bank'], ifscCode: [ - '', + bank?.ifscCode ?? '', Validators.pattern(/^[A-Za-z]{4}0[A-Za-z0-9]{6}$/) ], - bankCode: [''], - swiftCode: [''] + bankCode: [bank?.bankCode ?? ''], + swiftCode: [bank?.swiftCode ?? ''], + // Required: a bank with no payable cannot have drafts issued on it at all, so it is better + // refused while the bank is being set up than when a clerk is standing at a counter. + ddPayableGlAccountId: [ + bank?.ddPayableGlAccountId ?? null, + Validators.required + ] + }); + this.loadLiabilityGlAccounts(); + } + + private loadLiabilityGlAccounts(): void { + // type=2 is LIABILITY, usage=1 is DETAIL — the same filters the DD GL settings dialog uses for + // its own postable accounts. + this.http.get('/glaccounts?type=2&manualEntriesAllowed=true&disabled=false&usage=1').subscribe({ + next: (accounts) => (this.liabilityGlAccounts = accounts || []), + error: () => (this.liabilityGlAccounts = []) }); } @@ -297,9 +415,14 @@ export class AddSponsorBankDialogComponent implements OnInit { if (this.form.invalid) return; this.loading = true; - this.http.post('/sponsorbanks', this.form.value).subscribe({ + const request$ = this.isEdit + ? this.http.put(`/sponsorbanks/${this.data.bank.id}`, this.form.value) + : this.http.post('/sponsorbanks', this.form.value); + request$.subscribe({ next: () => { - this.snackBar.open('Sponsor Bank added successfully', 'Close', { duration: 3000 }); + this.snackBar.open(this.isEdit ? 'Sponsor Bank updated' : 'Sponsor Bank added successfully', 'Close', { + duration: 3000 + }); this.dialogRef.close(true); }, error: (err) => { diff --git a/src/app/sponsor-dd/sponsor-dd.service.ts b/src/app/sponsor-dd/sponsor-dd.service.ts index 9ee34c484..e811f563a 100644 --- a/src/app/sponsor-dd/sponsor-dd.service.ts +++ b/src/app/sponsor-dd/sponsor-dd.service.ts @@ -125,18 +125,22 @@ export class SponsorDDService { return this.http.get('/sponsordd/cashier-deposit-requests/available'); } - // ── Multi-DD slip (one account debit/cheque funds several drafts) ── + // ── Multi-DD slip (one account debit / cheque / GL debit funds several drafts) ── + // CASH is the one funding method a slip cannot use: a single counted pile of notes cannot be + // apportioned across drafts without the denomination total ceasing to prove what was taken. submitIssueRequestBatch( inventoryId: number, data: { clientId?: number; issuingBranchId: number; issuedDate: string; // yyyy-MM-dd - paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE'; + paymentMethod: 'ACCOUNT_DEBIT' | 'CHEQUE' | 'GL'; savingsAccountId?: number; currentAccountId?: number; chequeNumber?: string; chequeBankName?: string; + /** The GL account a GL-funded slip is debited from; omitted for every other method. */ + glAccountId?: number; lines: { beneficiaryName: string; payableAtBranch: string; @@ -149,8 +153,12 @@ export class SponsorDDService { return this.http.post(`/sponsordd/inventory/${inventoryId}/issue-request-batch`, data); } - approveIssueRequestBatch(batchId: number): Observable { - return this.http.post(`/sponsordd/issue-requests/batch/${batchId}/approve`, {}); + /** + * Approves a slip. Omitting approvedRequestIds issues every draft on it; supplying them issues + * only those, debits only for them, and rejects the rest — whose DD numbers return to stock. + */ + approveIssueRequestBatch(batchId: number, approvedRequestIds?: number[]): Observable { + return this.http.post(`/sponsordd/issue-requests/batch/${batchId}/approve`, { approvedRequestIds }); } rejectIssueRequestBatch(batchId: number, rejectionReason: string): Observable { diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 8081ebfd2..0ec45a382 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260918', - 'hash': 'dc7f6680' + 'version': '260921', + 'hash': '3c363d03' }, 'allow_switching_backend_instance': true };