diff --git a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts index 8f9e097ba..e27d63091 100644 --- a/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts +++ b/src/app/deposits/dds-accounts/dds-account-view/dds-account-view.component.ts @@ -666,7 +666,8 @@ export class DdsAccountViewComponent implements OnInit { id: account.id, agentId: account.agentId, interestCalc: account.interestCalc, - collectedToDate: account.collectedToDate + collectedToDate: account.collectedToDate, + openingBalance: account.openingBalance }, width: '960px', maxWidth: '96vw', diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html index cff4b29bb..c675c3c3f 100644 --- a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.html @@ -62,16 +62,6 @@ } - @if (pendingInterest > 0) { -
- - ₹{{ pendingInterest | number: '1.2-2' }} interest is calculated but not posted yet. Run - Interest Posting first — the closure is refused until it is in the balance. -
- } -
@@ -131,7 +121,8 @@ Either way, {{ data.agentCode }} is charged {{ scheme.prematureAgentCommissionPenalty | number: '1.0-2' }}% - of the commission owed to them — the round was not kept going.

} @@ -258,7 +249,12 @@ ₹{{ deposits | number: '1.2-2' }}
- Interest credited so far + Interest credited so far + @if (creditedThrough) { + till {{ creditedThrough | dateFormat }} + } + @if (interestCredited === null) { … @@ -267,18 +263,107 @@ }
+ + @if (interestDue > 0) { +
+ Interest still to credit + @if (interestThrough) { + till {{ interestThrough | dateFormat }} — added at closing + } + + + ₹{{ interestDue | number: '1.2-2' }} +
+ } @else if (interestThrough) { +
+ Interest is up to date + credited till {{ interestThrough | dateFormat }} +
+ } + +
+ Total interest earned + @if (interestThrough) { + {{ data.openingDate | dateFormat }} to {{ interestThrough | dateFormat }} + } + + ₹{{ totalInterest | number: '1.2-2' }} +
- Current balance + Balance on closing day + {{ businessDate | dateFormat }} — this day earns no interest, the money leaves today ₹{{ balance | number: '1.2-2' }}
+ + @if (interestDays.length > 0) { + + @if (showDays) { +
+ + + + + + + + + + + @for (day of interestDays; track day.date.getTime()) { + + + + + + + } + + + + + + + @if (interestDue > 0) { + + + + + } + + + + + +
DateBalanceInterest
{{ day.date | dateFormat }}₹{{ day.balance | number: '1.2-2' }}₹{{ day.interest | number: '1.4-4' }} + {{ + day.credited ? 'Credited' : 'To credit' + }} +
Credited so far₹{{ interestCredited ?? 0 | number: '1.2-2' }}
Added at closing+ ₹{{ interestDue | number: '1.2-2' }}
Total interest₹{{ totalInterest | number: '1.2-2' }}
+

+ Balance × {{ data.interestRate | number: '1.0-2' }}% ÷ 365 for each day. Each run's total is rounded to + paise, so the totals can differ from the day figures by a paisa. {{ businessDate | dateFormat }}, the + closing day, earns nothing. +

+
+ } + } + @if (borneBy === 'CUSTOMER') {
Interest taken back ₹{{ interestCredited ?? 0 | number: '1.2-2' }} × - {{ data.interestRate - reducedRate | number: '1.0-2' }}% ÷ + >₹{{ totalInterest | number: '1.2-2' }} × {{ data.interestRate - reducedRate | number: '1.0-2' }}% ÷ {{ data.interestRate | number: '1.0-2' }}% @@ -319,13 +404,19 @@
Commission penalty - @if (scheme?.prematureAgentCommissionPenalty > 0) { + @if (agentPenalty && (agentPenalty.penaltyRate ?? 0) > 0) { {{ scheme.prematureAgentCommissionPenalty | number: '1.0-2' }}% of ₹{{ - agentCommission ?? 0 | number: '1.2-2' - }} - owed₹{{ agentPenalty.collection ?? 0 | number: '1.2-2' }} collected on this account × + {{ agentPenalty.penaltyRate | number: '1.0-2' }}% + @if (agentPenalty.commissionEarned !== null && agentPenalty.commissionEarned !== undefined) { + {{ penaltyCapped ? 'Capped at' : 'Never more than' }} ₹{{ + agentPenalty.commissionEarned | number: '1.2-2' + }} + this account earned them ({{ agentPenalty.commissionRate | number: '1.0-2' }}% commission) + } } ₹{{ commissionPenalty | number: '1.2-2' }} diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss index 1044fbddf..ee0803ede 100644 --- a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.scss @@ -449,6 +449,23 @@ font-variant-numeric: tabular-nums; } + &--ok { + color: #15803d; + } + + &--interest { + margin: 4px 0; + padding: 8px 10px; + border-radius: 8px; + background: #f0fdf4; + color: #15803d; + font-weight: 600; + + b { + font-size: 0.95rem; + } + } + &--sum { margin-top: 2px; border-top: 1px dashed #cbd5e1; @@ -526,6 +543,102 @@ } } +/* The interest day by day, opened from the calculation panel */ +.pcd-days { + max-height: 260px; + margin: 2px 0 6px; + overflow: auto; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + + &__toggle { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 4px 0 6px; + padding: 0; + border: none; + background: none; + color: #4338ca; + font: inherit; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + table { + width: 100%; + border-collapse: collapse; + font-size: 0.76rem; + } + + th, + td { + padding: 5px 8px; + border-bottom: 1px solid #f1f5f9; + text-align: left; + white-space: nowrap; + } + + th { + position: sticky; + top: 0; + background: #f8fafc; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + tfoot td { + font-weight: 600; + color: #334155; + } + + &__due td { + color: #15803d; + } + + &__sum td { + border-top: 1px solid #cbd5e1; + color: #0f172a; + } + + &__tag { + padding: 1px 7px; + border-radius: 999px; + background: #f1f5f9; + font-size: 0.68rem; + font-weight: 600; + color: #475569; + + &--due { + background: #dcfce7; + color: #15803d; + } + } + + &__note { + margin: 0; + padding: 6px 8px 8px; + font-size: 0.7rem; + line-height: 1.5; + color: #94a3b8; + white-space: normal; + } +} + @media (width <= 760px) { .pcd-grid { grid-template-columns: minmax(0, 1fr); diff --git a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts index 69d895c57..31d2be321 100644 --- a/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts +++ b/src/app/deposits/party-master/premature-close-dialog/premature-close-dialog.component.ts @@ -23,6 +23,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; /** Custom Services */ import { OrganizationService } from 'app/organization/organization.service'; import { ProductsService } from 'app/products/products.service'; +import { SettingsService } from 'app/settings/settings.service'; /** Custom Pipes */ import { DateFormatPipe } from 'app/pipes/date-format.pipe'; @@ -66,6 +67,7 @@ import { DateFormatPipe } from 'app/pipes/date-format.pipe'; export class PrematureCloseDialogComponent implements OnInit { private organizationService = inject(OrganizationService); private productsService = inject(ProductsService); + private settingsService = inject(SettingsService); private dialogRef = inject(MatDialogRef); data = inject(MAT_DIALOG_DATA); @@ -183,6 +185,7 @@ export class PrematureCloseDialogComponent implements OnInit { this.organizationService.getPartyTransactionsOfParty(this.data.id).subscribe({ next: (txns: any) => { const list: any[] = Array.isArray(txns) ? txns : txns?.pageItems || []; + this.transactions = list; this.interestCredited = list .filter((t) => PrematureCloseDialogComponent.INTEREST_TYPES.includes(t.transactionType)) .reduce((sum, t) => sum + (t.direction === 'DEBIT' ? -1 : 1) * Number(t.amount || 0), 0); @@ -190,11 +193,18 @@ export class PrematureCloseDialogComponent implements OnInit { error: () => (this.interestCredited = null) }); } - // The commission the agent is owed now, which the scheme's penalty is a share of. - if (this.data.agentId) { - this.organizationService.getAgent(this.data.agentId).subscribe({ - next: (agent: any) => (this.agentCommission = Number(agent?.commission || 0)), - error: () => (this.agentCommission = null) + // Interest earned through yesterday but not yet credited. The closure credits it first, so the + // figures below start from it rather than from a balance that is a few days behind. + if (this.data.id) { + this.organizationService.getPartyInterestDue(this.data.id).subscribe({ + next: (due: any) => { + this.interestDue = Number(due?.interestDue || 0); + this.interestThrough = due?.through ?? null; + this.creditedThrough = due?.creditedThrough ?? null; + // The agent's penalty as the server works it out: on this account's collection alone. + this.agentPenalty = due?.agentPenalty ?? null; + }, + error: () => (this.interestDue = 0) }); } } @@ -207,33 +217,113 @@ export class PrematureCloseDialogComponent implements OnInit { /** Interest credited to the account so far; null until read (or if it could not be). */ interestCredited: number | null = null; - /** What the agent is owed in commission today; null until read. */ - agentCommission: number | null = null; + /** Interest earned through the day before closing but not yet credited - credited by the closure itself. */ + interestDue = 0; + /** The last day that earns (the day before the business date), and the day interest is credited through now. */ + interestThrough: string | null = null; + creditedThrough: string | null = null; + + /** Everything posted to the account, for the day-by-day interest table. */ + private transactions: any[] = []; + + /** Whether the day-by-day interest table is open. */ + showDays = false; + + private static dayKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + /** + * Each earning day from opening to the day before closing: the balance at the end of that day - every + * movement up to it, interest postings included, as the server's daily product counts it - and the + * interest it earns (balance × rate ÷ 365). Days up to the last credited day are marked credited; the + * rest are what the closure credits first. The server rounds each run's total, not each day, so the + * per-day figures are shown to four places and the totals come from the server. + */ + get interestDays(): { date: Date; balance: number; interest: number; credited: boolean }[] { + const opened = PrematureCloseDialogComponent.toDate(this.data.openingDate); + const rate = Number(this.data.interestRate || 0); + if (!opened || rate <= 0) { + return []; + } + const last = new Date(this.businessDate); + last.setDate(last.getDate() - 1); + const creditedTo = this.creditedThrough ? PrematureCloseDialogComponent.toDate(this.creditedThrough) : null; + const byDay = new Map(); + for (const t of this.transactions) { + const on = PrematureCloseDialogComponent.toDate(t.transactionDate); + if (on) { + const key = PrematureCloseDialogComponent.dayKey(on); + byDay.set(key, (byDay.get(key) || 0) + (t.direction === 'DEBIT' ? -1 : 1) * Number(t.amount || 0)); + } + } + const rows: { date: Date; balance: number; interest: number; credited: boolean }[] = []; + let balance = Number(this.data.openingBalance || 0); + for ( + let day = new Date(opened); + day.getTime() <= last.getTime() && rows.length < 400; + day.setDate(day.getDate() + 1) + ) { + balance += byDay.get(PrematureCloseDialogComponent.dayKey(day)) || 0; + rows.push({ + date: new Date(day), + balance, + interest: (balance * rate) / 36500, + credited: !!creditedTo && day.getTime() <= creditedTo.getTime() + }); + } + return rows; + } + + /** + * The agent's penalty on this account, from the server: its collection, the scheme's penalty rate, + * the agent's commission rate and what the account earned them (the cap), and the penalty itself. + */ + agentPenalty: { + collection?: number; + penaltyRate?: number; + commissionRate?: number | null; + commissionEarned?: number | null; + penalty?: number; + } | null = null; private static round2(value: number): number { return Math.round((value + Number.EPSILON) * 100) / 100; } - get balance(): number { + /** The balance today, before the interest still owed is credited. */ + get currentBalance(): number { return Number(this.data.currentBalance || 0); } - /** Interest calculated but not posted - the server refuses to close until it is posted. */ - get pendingInterest(): number { - return Number(this.data.interestCalc || 0); + /** The balance on the closing day, once the interest owed through yesterday is credited. */ + get balance(): number { + return PrematureCloseDialogComponent.round2(this.currentBalance + this.interestDue); + } + + /** All interest the account will have earned by closing - credited so far plus what is still owed. */ + get totalInterest(): number { + return PrematureCloseDialogComponent.round2((this.interestCredited ?? 0) + this.interestDue); + } + + /** The bank's working day, which the server closes on - not the computer's clock. */ + get businessDate(): Date { + const date = new Date(this.settingsService.businessDate || new Date()); + date.setHours(0, 0, 0, 0); + return date; } /** What the customer paid in: the balance less the interest credited on it. */ get deposits(): number { const collected = Number(this.data.collectedToDate || 0); - return collected > 0 ? collected : Math.max(0, this.balance - (this.interestCredited ?? 0)); + return collected > 0 ? collected : Math.max(0, this.currentBalance - (this.interestCredited ?? 0)); } /** Customer bears it: credited interest scaled by the share of the rate given up. */ get customerRecovery(): number { const rate = Number(this.data.interestRate || 0); const reduction = Number(this.scheme?.prematureInterestReduction || 0); - const credited = this.interestCredited ?? 0; + const credited = this.totalInterest; if (rate <= 0 || reduction <= 0 || credited <= 0) { return 0; } @@ -244,9 +334,9 @@ export class PrematureCloseDialogComponent implements OnInit { return PrematureCloseDialogComponent.round2(this.balance - this.customerRecovery); } - /** Days from today to the month's end, today counted - the days the agent funds. */ + /** Days from the closing day to the month's end, the closing day counted - the days the agent funds. */ get agentTopUpDays(): number { - const today = new Date(); + const today = this.businessDate; const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate(); return lastDay - today.getDate() + 1; } @@ -264,13 +354,16 @@ export class PrematureCloseDialogComponent implements OnInit { return PrematureCloseDialogComponent.round2(this.balance + this.agentTopUp); } - /** The share of the agent's commission taken whichever way it is settled. */ + /** The agent's penalty on this account, taken whichever way it is settled. */ get commissionPenalty(): number { - const rate = Number(this.scheme?.prematureAgentCommissionPenalty || 0); - if (rate <= 0 || !this.agentCommission) { - return 0; - } - return PrematureCloseDialogComponent.round2((this.agentCommission * rate) / 100); + return Number(this.agentPenalty?.penalty || 0); + } + + /** Whether the penalty was cut down to what the account earned the agent. */ + get penaltyCapped(): boolean { + const earned = this.agentPenalty?.commissionEarned; + const uncapped = (Number(this.agentPenalty?.collection || 0) * Number(this.agentPenalty?.penaltyRate || 0)) / 100; + return earned !== null && earned !== undefined && uncapped > Number(earned); } /** What the customer receives on the option picked, or null before one is picked. */ @@ -294,13 +387,21 @@ export class PrematureCloseDialogComponent implements OnInit { /** The API sends dates as [year, month, day]; month is 1-based there and 0-based in Date. */ private static toDate(value: any): Date | null { - const date = - Array.isArray(value) && value.length >= 3 - ? new Date(value[0], value[1] - 1, value[2]) - : value - ? new Date(value) - : null; - return date && !isNaN(date.getTime()) ? date : null; + if (Array.isArray(value) && value.length >= 3) { + return new Date(value[0], value[1] - 1, value[2]); + } + // A bare 'yyyy-MM-dd' is a calendar day: read in local time, not as UTC midnight - which in India is + // 05:30 the same day and would push every day-by-day comparison off by one. + const day = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) : null; + if (day) { + return new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3])); + } + const date = value ? new Date(value) : null; + if (!date || isNaN(date.getTime())) { + return null; + } + date.setHours(0, 0, 0, 0); + return date; } private static readonly DAY_MS = 24 * 60 * 60 * 1000; @@ -320,8 +421,7 @@ export class PrematureCloseDialogComponent implements OnInit { if (!opened) { return 0; } - const today = new Date(); - today.setHours(0, 0, 0, 0); + const today = this.businessDate; const run = Math.round((today.getTime() - opened.getTime()) / PrematureCloseDialogComponent.DAY_MS) + 1; return Math.min(this.termDays, Math.max(0, run)); } diff --git a/src/app/organization/organization.service.ts b/src/app/organization/organization.service.ts index 2e861671f..92f281ea7 100644 --- a/src/app/organization/organization.service.ts +++ b/src/app/organization/organization.service.ts @@ -490,6 +490,14 @@ export class OrganizationService { * @param {string | number} partyId DDS account (party) id. * @returns {Observable} Everything posted to that DDS account, most recent first. */ + /** + * Interest a DDS account has earned through the day before the business date but not yet been + * credited - what an early closure today credits before it settles. + */ + getPartyInterestDue(partyId: string | number): Observable { + return this.http.get(`/dds/parties/${partyId}/interestdue`); + } + getPartyTransactionsOfParty(partyId: string | number): Observable { return this.http.get('/dds/partytransactions', { params: new HttpParams().set('partyId', String(partyId)) diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html index bb58777ab..a4522dc3b 100644 --- a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html +++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.html @@ -165,7 +165,7 @@ - Agent: commission reduced by (%) + Agent penalty (% of this account's collection) - Charged whoever bears the interest — the round was not kept going. + Taken off the agent's next commission, whoever bears the interest - never more than the commission the + account earned them.
diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss index fe521eece..651fe48b2 100644 --- a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss +++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.scss @@ -285,6 +285,11 @@ $text-soft: #94a3b8; background: #fef3c7; color: #92400e; } + + &--green { + background: #dcfce7; + color: #166534; + } } // ── Footer ─────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts index 17cb0a873..fc3be0c26 100644 --- a/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts +++ b/src/app/products/dds-account-types/create-dds-account-type/create-dds-account-type.component.ts @@ -24,8 +24,9 @@ import { SettingsService } from 'app/settings/settings.service'; import { AccountingService } from 'app/accounting/accounting.service'; import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice'; -/** acc_gl_account classification: 2 liability, 5 expense. */ +/** acc_gl_account classification: 2 liability, 4 income, 5 expense. */ const GL_LIABILITY = 2; +const GL_INCOME = 4; const GL_EXPENSE = 5; /** One ledger account the product names, as a row on the Accounting tab. */ @@ -34,7 +35,7 @@ interface AccountingRow { label: string; inputLabel: string; hint: string; - pill: 'blue' | 'amber'; + pill: 'blue' | 'amber' | 'green'; pillLabel: string; required: boolean; options: GLAccount[]; @@ -93,7 +94,9 @@ export class CreateDdsAccountTypeComponent implements OnInit { readonly accountingControls = [ 'depositLiabilityGlId', 'interestExpenseGlId', - 'commissionExpenseGlId' + 'commissionExpenseGlId', + 'prematureIncomeGlId', + 'tdsPayableGlId' ]; /** The Accounting tab's rows, grouped by kind as the savings product's are. */ @@ -196,6 +199,16 @@ export class CreateDdsAccountTypeComponent implements OnInit { commissionExpenseGlId: [ null, Validators.required + ], + /** What an early closure earns the bank: the customer's interest given back and the agent's penalty. */ + prematureIncomeGlId: [ + null, + Validators.required + ], + /** Where TDS withheld from the agents' commission (Section 194H) is owed until it is paid over. */ + tdsPayableGlId: [ + null, + Validators.required ] }); @@ -226,7 +239,9 @@ export class CreateDdsAccountTypeComponent implements OnInit { prematureAgentCommissionPenalty: accountType.prematureAgentCommissionPenalty ?? 1, depositLiabilityGlId: accountType.depositLiabilityGlId ?? null, interestExpenseGlId: accountType.interestExpenseGlId ?? null, - commissionExpenseGlId: accountType.commissionExpenseGlId ?? null + commissionExpenseGlId: accountType.commissionExpenseGlId ?? null, + prematureIncomeGlId: accountType.prematureIncomeGlId ?? null, + tdsPayableGlId: accountType.tdsPayableGlId ?? null }); }); } @@ -258,16 +273,16 @@ export class CreateDdsAccountTypeComponent implements OnInit { (a: any) => a.usage?.id === 1 && !a.disabled ); const ofType = (...types: number[]) => rows.filter((a: any) => types.includes(a.type?.id)); - this.buildAccountingSections(ofType(GL_LIABILITY), ofType(GL_EXPENSE)); + this.buildAccountingSections(ofType(GL_LIABILITY), ofType(GL_EXPENSE), ofType(GL_INCOME)); }, error: () => { this.glLoadFailed = true; - this.buildAccountingSections([], []); + this.buildAccountingSections([], [], []); } }); } - private buildAccountingSections(liabilities: GLAccount[], expenses: GLAccount[]): void { + private buildAccountingSections(liabilities: GLAccount[], expenses: GLAccount[], incomes: GLAccount[]): void { this.accountingSections = [ { key: 'liabilities', @@ -282,6 +297,16 @@ export class CreateDdsAccountTypeComponent implements OnInit { pillLabel: 'Liability', required: true, options: liabilities + }, + { + controlName: 'tdsPayableGlId', + label: 'TDS Payable (194H)', + inputLabel: 'TDS payable account', + hint: "TDS withheld from the agents' commission on this product - credited when the commission is paid, until it is paid over to the tax department.", + pill: 'blue', + pillLabel: 'Liability', + required: true, + options: liabilities } ] }, @@ -310,6 +335,22 @@ export class CreateDdsAccountTypeComponent implements OnInit { options: expenses } ] + }, + { + key: 'income', + title: 'Income', + rows: [ + { + controlName: 'prematureIncomeGlId', + label: 'Premature Closure Income', + inputLabel: 'Premature closure income account', + hint: 'What closing early earns the bank: the interest a customer gives back (Premature Closure tab), on the day of closing. The agent penalty is not booked here - it comes off their commission, which reverses that much commission expense.', + pill: 'green', + pillLabel: 'Income', + required: true, + options: incomes + } + ] } ]; }