done
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-08-30 11:09:14 +05:30
parent 07a4c8b8e3
commit 9f95d01196
57 changed files with 5705 additions and 681 deletions
+10 -9
View File
@@ -213,19 +213,11 @@ export class ProductsService {
* @param ratePercentage Rate per annum.
* @returns {Observable<any>} `{ totalAmount, products: [{ productName, amount, eligible, reason }] }`.
*/
previewAllProductsDividend(
fromDate: string,
toDate: string,
ratePercentage: number,
dividendBasis: string
): Observable<any> {
previewAllProductsDividend(fromDate: string, toDate: string, ratePercentage: number): Observable<any> {
const params: any = { fromDate, toDate };
if (ratePercentage != null) {
params.ratePercentage = ratePercentage;
}
if (dividendBasis) {
params.dividendBasis = dividendBasis;
}
return this.http.get('/shareproducts/dividends/preview', { params });
}
@@ -305,6 +297,15 @@ export class ProductsService {
return this.http.get(`/shareproduct/${shareProductId}/dividend/${dividendId}/rounding-residue`);
}
/**
* The heading of a Share Dividend Distribution Report: the rate the board resolved, what it came
* to, and the date it became payable. Those live on the declaration rather than on any member
* row, so they are fetched once rather than repeated across the breakdown.
*/
getDividendReportHeader(dividendId: any): Observable<any> {
return this.http.get(`/clerk/dividend-dashboard/declaration/${dividendId}/report-header`);
}
/** Every declaration's rounding residue for one product, newest first, with a running total. */
getDividendRoundingResidueHistory(shareProductId: any): Observable<any> {
return this.http.get(`/shareproduct/${shareProductId}/dividend/rounding-residue`);
@@ -182,10 +182,28 @@ export class AllProductsDividendsComponent implements OnInit {
}
/**
* Opens a declaration in the existing per-product view, which holds the member-by-member
* allocation and the approve action.
* Opens a declaration where the officer can actually act on it.
*
* One still awaiting approval goes to the Approval Centre, because approving is what the officer
* came to do and that is where the approve action lives alongside every other pending
* declaration — landing them on a read-only page they then have to navigate away from is a
* detour. The declaration is named in the query string so the Centre can open its member-by-member
* breakdown straight away rather than making them find the row again.
*
* One already approved has nothing to act on, so it goes to the per-product view to be read.
*/
viewDividend(row: any): void {
if (!row.approved) {
this.router.navigate(
[
'/checker-inbox-and-tasks',
'dividend-requests'
],
{ queryParams: { productId: row.productId, dividendId: row.dividendId } }
);
return;
}
this.router.navigate(
[
'/products',
@@ -194,7 +212,7 @@ export class AllProductsDividendsComponent implements OnInit {
'dividends',
row.dividendId
],
{ queryParams: { status: row.approved ? 'Approved' : 'Initiated' } }
{ queryParams: { status: 'Approved' } }
);
}
}
@@ -12,7 +12,7 @@
<div class="pl-header-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div>
<div class="pl-header-text">
<h2>Declare Dividend</h2>
<p>One rate for the year, applied to every share product at once.</p>
<p>One rate for the year, applied to every member’s share investment.</p>
</div>
</div>
</div>
@@ -70,34 +70,15 @@
</mat-form-field>
</div>
<div class="dd-basis">
<span class="dd-basis-label">Dividend Basis</span>
<mat-radio-group formControlName="dividendBasis">
<mat-radio-button value="RATE_ON_CAPITAL">Rate on share capital</mat-radio-button>
<mat-radio-button value="PERCENT_OF_PROFIT">Percentage of distributable profit</mat-radio-button>
</mat-radio-group>
<p class="dd-hint">
@if (declareForm.value.dividendBasis === 'PERCENT_OF_PROFIT') {
A share of the year's distributable profit, split across products in proportion to the capital each
carries.
} @else {
Each member receives this rate on their own paid-up capital, pro-rated for how long they held it.
}
</p>
</div>
<div class="dd-fields">
<mat-form-field appearance="outline">
<mat-label>
{{
declareForm.value.dividendBasis === 'PERCENT_OF_PROFIT'
? 'Percentage of Profit'
: 'Dividend Rate (% per annum)'
}}
</mat-label>
<mat-label>Dividend Rate (% per annum)</mat-label>
<input matInput type="number" step="0.01" min="0.01" max="100" formControlName="ratePercentage" />
<span matSuffix>&nbsp;%&nbsp;</span>
</mat-form-field>
<p class="dd-hint">
Applied to each member's own share investment, pro-rated for how long they held it.
</p>
</div>
@if (loadingPreview) {
@@ -109,51 +90,62 @@
}
@if (preview && !loadingPreview) {
<!-- The appropriation waterfall. Shown in full because the officer is approving a
division of the society's profit, not a single number: ₹2,22,000 on its own gives no
way to tell a small profit from a large one already heavily appropriated, and the
reserve and education rates are configurable so their amounts cannot be inferred. -->
<!-- The calculation itself: share investment x rate, and nothing else. Profit does not
size the dividend, so it does not appear here — it is reported below only as the
account the payout is drawn from. -->
<div class="dd-preview">
<div class="dd-preview-head">Where the money comes from</div>
<div class="dd-preview-head">How the dividend is worked out</div>
<div class="dd-preview-row">
<span>
Net profit for {{ preview.financialYear }}
<span class="dd-gl">{{ preview.profitGlCode }}</span>
</span>
<span class="dd-amount">{{ preview.netProfit | number: '1.2-2' }}</span>
<span>Share investment (weighted for holding period)</span>
<span class="dd-amount">{{ preview.totalWeightedCapital | number: '1.2-2' }}</span>
</div>
<div class="dd-preview-row dd-preview-row--deduction">
<span>Less statutory reserve ({{ preview.statutoryReservePercentage | number: '1.0-2' }}%)</span>
<span class="dd-amount">− {{ preview.statutoryReserveAmount | number: '1.2-2' }}</span>
</div>
<div class="dd-preview-row dd-preview-row--deduction">
<span>Less education fund ({{ preview.educationFundPercentage | number: '1.0-2' }}%)</span>
<span class="dd-amount">− {{ preview.educationFundAmount | number: '1.2-2' }}</span>
<div class="dd-preview-row dd-preview-row--muted">
<span>Dividend rate</span>
<span class="dd-amount">{{ declareForm.value.ratePercentage || 0 }}%</span>
</div>
<div class="dd-preview-row dd-preview-row--subtotal">
<span>Distributable profit</span>
<span class="dd-amount">{{ preview.distributableProfit | number: '1.2-2' }}</span>
<span>Dividend payable</span>
<span class="dd-amount">{{ preview.totalAmount | number: '1.2-2' }}</span>
</div>
</div>
<!-- Where the money is drawn from. A funding position, not a basis: the amount above is
already fixed by capital, and this only says whether the profit account can pay it. -->
<div class="dd-preview">
<div class="dd-preview-head">Funded from</div>
<div class="dd-preview-row">
<span>
Bank profit for {{ preview.financialYear }}
<span class="dd-gl">{{ preview.profitGlCode }}</span>
</span>
<span class="dd-amount">{{ preview.fundingAvailable | number: '1.2-2' }}</span>
</div>
@if (declareForm.value.dividendBasis === 'RATE_ON_CAPITAL') {
<div class="dd-preview-row dd-preview-row--deduction">
<span>Less dividend payable</span>
<span class="dd-amount">− {{ preview.totalAmount | number: '1.2-2' }}</span>
</div>
@if (isUnderfunded) {
<div class="dd-note dd-note--error">
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.
</div>
} @else {
<div class="dd-preview-row dd-preview-row--muted">
<span>Weighted share capital</span>
<span class="dd-amount">{{ preview.totalWeightedCapital | number: '1.2-2' }}</span>
<span>Left in profit (swept to Bank Profit on approval)</span>
<span class="dd-amount">{{ preview.undistributedRemainder | number: '1.2-2' }}</span>
</div>
}
</div>
<div class="dd-preview">
<div class="dd-preview-head">
{{
declareForm.value.dividendBasis === 'PERCENT_OF_PROFIT'
? (declareForm.value.ratePercentage || 0) + '% of distributable profit, split by capital'
: (declareForm.value.ratePercentage || 0) + '% on each member’s capital'
}}
{{ declareForm.value.ratePercentage || 0 }}% on each member’s share investment
</div>
@for (product of eligibleProducts; track product.productId) {
@@ -168,13 +160,6 @@
<span>Total to declare</span>
<span class="dd-amount">{{ payableTotal | number: '1.2-2' }}</span>
</div>
<!-- What stays behind. Called out because on approval it leaves the distributable
pool for good — no later year's dividend can draw on it. -->
<div class="dd-preview-row dd-preview-row--muted">
<span>Stays in profit (swept to Bank Profit on approval)</span>
<span class="dd-amount">{{ preview.undistributedRemainder | number: '1.2-2' }}</span>
</div>
}
@for (product of skippedProducts; track product.productId) {
@@ -186,13 +171,6 @@
</div>
}
@if (preview.exceedsDistributableProfit) {
<div class="dd-note dd-note--error">
This is more than the distributable profit for {{ preview.financialYear }}. The declaration will be
rejected.
</div>
}
@if (eligibleProducts.length === 0) {
<div class="dd-note dd-note--error">
No share product is eligible for this year, so there is nothing to declare.
@@ -47,36 +47,14 @@ form {
}
}
/* Sits directly under the rate field, where it explains what the number just typed applies to. */
.dd-hint {
flex: 1 1 100%;
margin: 0 0 16px;
font-size: 13px;
color: #64748b;
}
/* Which basis is chosen changes what the percentage means, so it sits above the rate field rather
than below it — the officer picks the meaning before typing the number. */
.dd-basis {
margin: 4px 0 8px;
mat-radio-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.dd-hint {
margin: 6px 0 0;
}
}
.dd-basis-label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: #334155;
}
/* The cost of the rate, per product. The officer is committing to a rate, but what leaves the
society is an amount — so the amount is shown before the commit, not after. */
.dd-preview {
@@ -16,7 +16,6 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatOptionModule } from '@angular/material/core';
import { MatRadioModule } from '@angular/material/radio';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -31,6 +30,10 @@ import { SettingsService } from '../../../../settings/settings.service';
* much capital each product's members held and for how long. So the officer enters the rate and the
* year, and the preview shows what it works out to per product before anything is committed. That
* preview is the point of the screen: a rate looks harmless until you see the amount it implies.
*
* The rate applies to members' share investment and to nothing else. Profit does not size the
* dividend — it only funds it, so the screen leads with capital x rate and reports the profit
* account only as the source the payout is drawn from.
*/
@Component({
selector: 'mifosx-declare-dividend',
@@ -44,7 +47,6 @@ import { SettingsService } from '../../../../settings/settings.service';
MatInputModule,
MatSelectModule,
MatOptionModule,
MatRadioModule,
MatProgressBarModule,
FaIconComponent
]
@@ -77,9 +79,6 @@ export class DeclareDividendComponent implements OnInit {
'',
Validators.required
],
// Rate on capital is the default because it is what a dividend rate means in an AGM
// resolution: each member receives that rate on their own capital, not a slice of a pool.
dividendBasis: ['RATE_ON_CAPITAL'],
ratePercentage: [
'',
[
@@ -131,21 +130,19 @@ export class DeclareDividendComponent implements OnInit {
this.loadingPreview = true;
this.previewError = null;
this.productsService
.previewAllProductsDividend(year.start, year.end, rate, this.declareForm.value.dividendBasis)
.subscribe({
next: (response: any) => {
this.preview = response;
this.loadingPreview = false;
},
error: (error: any) => {
// Cleared rather than left stale, so a failed refresh can never be read as the cost of the
// rate now on screen.
this.preview = null;
this.previewError = error?.error?.defaultUserMessage ?? 'Could not work out what this rate would cost.';
this.loadingPreview = false;
}
});
this.productsService.previewAllProductsDividend(year.start, year.end, rate).subscribe({
next: (response: any) => {
this.preview = response;
this.loadingPreview = false;
},
error: (error: any) => {
// Cleared rather than left stale, so a failed refresh can never be read as the cost of the
// rate now on screen.
this.preview = null;
this.previewError = error?.error?.defaultUserMessage ?? 'Could not work out what this rate would cost.';
this.loadingPreview = false;
}
});
}
/** Products that will actually receive a declaration. */
@@ -163,8 +160,19 @@ export class DeclareDividendComponent implements OnInit {
return this.eligibleProducts.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
}
/**
* Whether the profit account can cover what this rate works out to.
*
* The rate decides the amount; profit only has to be able to pay it. Surfaced here so the officer
* sees the shortfall on screen rather than having the command layer reject the declaration after
* they have already committed to it — the same ceiling is enforced there either way.
*/
get isUnderfunded(): boolean {
return !!this.preview?.exceedsDistributableProfit;
}
get canSubmit(): boolean {
return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0;
return this.declareForm.valid && !this.submitting && this.eligibleProducts.length > 0 && !this.isUnderfunded;
}
submit(): void {
@@ -179,7 +187,6 @@ export class DeclareDividendComponent implements OnInit {
.createAllProductsDividend({
dividendPeriodStartDate: year.start,
dividendPeriodEndDate: year.end,
dividendBasis: this.declareForm.value.dividendBasis,
ratePercentage: String(this.declareForm.value.ratePercentage)
})
.subscribe({
@@ -80,30 +80,6 @@
}
<span></span>
</div>
@if (profitBreakdown.statutoryReservePercentage > 0) {
<div class="dividend-waterfall__row dividend-waterfall__row--deduction">
<span
>&minus; {{ 'labels.inputs.Statutory Reserve Fund' | translate }} ({{
profitBreakdown.statutoryReservePercentage
}}%)</span
>
<span>{{ profitBreakdown.statutoryReserveAmount | number: '1.2-2' }}</span>
</div>
}
@if (profitBreakdown.educationFundPercentage > 0) {
<div class="dividend-waterfall__row dividend-waterfall__row--deduction">
<span
>&minus; {{ 'labels.inputs.Education Fund' | translate }} ({{
profitBreakdown.educationFundPercentage
}}%)</span
>
<span>{{ profitBreakdown.educationFundAmount | number: '1.2-2' }}</span>
</div>
}
<div class="dividend-waterfall__row dividend-waterfall__row--subtotal">
<span>{{ 'labels.inputs.Distributable Profit' | translate }}</span>
<span>{{ profitBreakdown.distributableProfit | number: '1.2-2' }}</span>
</div>
@if (profitBreakdown.alreadyDeclared > 0) {
<div class="dividend-waterfall__row dividend-waterfall__row--deduction">
<span>&minus; {{ 'labels.inputs.Already declared for other products' | translate }}</span>
@@ -126,40 +102,9 @@
</div>
}
<!-- Which question the percentage below answers. RATE_ON_CAPITAL is the default because
it is what a rate in an AGM resolution promises each member. -->
<div class="dividend-mode">
<span class="dividend-mode__label">{{ 'labels.inputs.Dividend Basis' | translate }}</span>
<mat-radio-group [value]="dividendMode" (change)="onModeChange($event.value)">
<mat-radio-button value="RATE_ON_CAPITAL">
{{ 'labels.inputs.Rate on share capital' | translate }}
</mat-radio-button>
<mat-radio-button value="PERCENT_OF_PROFIT">
{{ 'labels.inputs.Percentage of distributable profit' | translate }}
</mat-radio-button>
</mat-radio-group>
<p class="dividend-mode__hint">
@if (dividendMode === 'RATE_ON_CAPITAL') {
{{
'labels.inputs.Each member receives this rate on their own paid-up capital, pro-rated for how long they held it'
| translate
}}.
} @else {
{{
'labels.inputs.This share of distributable profit is split across members by holding period — it is not the rate any individual member receives'
| translate
}}.
}
</p>
</div>
<mat-form-field>
<mat-label>
@if (dividendMode === 'RATE_ON_CAPITAL') {
{{ 'labels.inputs.Dividend Rate' | translate }} (% {{ 'labels.inputs.per annum' | translate }})
} @else {
{{ 'labels.inputs.Dividend Percentage' | translate }} (%)
}
{{ 'labels.inputs.Dividend Rate' | translate }} (% {{ 'labels.inputs.per annum' | translate }})
</mat-label>
<input matInput type="number" min="0" max="100" step="0.01" required formControlName="dividendPercentage" />
@if (createDividendForm.controls.dividendPercentage.hasError('required')) {
@@ -170,41 +115,44 @@
}
</mat-form-field>
<p class="dividend-mode__hint">
{{
'labels.inputs.Each member receives this rate on their own paid-up capital, pro-rated for how long they held it'
| translate
}}.
</p>
@if (rateCeilingError) {
<p class="dividend-profit-note dividend-profit-note--error">{{ rateCeilingError }}</p>
}
@if (dividendMode === 'RATE_ON_CAPITAL') {
@if (loadingCapital) {
<p class="dividend-profit-note">
{{ 'labels.inputs.Calculating eligible share capital' | translate }}...
</p>
} @else if (capitalLookupError) {
<p class="dividend-profit-note dividend-profit-note--error">
{{ 'labels.inputs.Could not calculate eligible share capital for this period' | translate }}.
</p>
} @else if (capitalPreview !== null) {
<div class="dividend-waterfall">
<div class="dividend-waterfall__row">
<span>{{ 'labels.inputs.Eligible share capital (weighted)' | translate }}</span>
<span>{{ capitalPreview.weightedCapital | number: '1.2-2' }}</span>
</div>
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
<span>{{ 'labels.inputs.Eligible accounts' | translate }}</span>
<span>{{ capitalPreview.eligibleAccounts }}</span>
</div>
@if (capitalPreview.eligibleAccounts === 0 && capitalPreview.minimumActivePeriodDays > 0) {
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
<span
>{{ 'labels.inputs.No member has held shares for the minimum active period of' | translate }}
{{ capitalPreview.minimumActivePeriodDays }}
{{ 'labels.inputs.days' | translate }}</span
>
<span></span>
</div>
}
@if (loadingCapital) {
<p class="dividend-profit-note">{{ 'labels.inputs.Calculating eligible share capital' | translate }}...</p>
} @else if (capitalLookupError) {
<p class="dividend-profit-note dividend-profit-note--error">
{{ 'labels.inputs.Could not calculate eligible share capital for this period' | translate }}.
</p>
} @else if (capitalPreview !== null) {
<div class="dividend-waterfall">
<div class="dividend-waterfall__row">
<span>{{ 'labels.inputs.Eligible share capital (weighted)' | translate }}</span>
<span>{{ capitalPreview.weightedCapital | number: '1.2-2' }}</span>
</div>
}
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
<span>{{ 'labels.inputs.Eligible accounts' | translate }}</span>
<span>{{ capitalPreview.eligibleAccounts }}</span>
</div>
@if (capitalPreview.eligibleAccounts === 0 && capitalPreview.minimumActivePeriodDays > 0) {
<div class="dividend-waterfall__row dividend-waterfall__row--detail">
<span
>{{ 'labels.inputs.No member has held shares for the minimum active period of' | translate }}
{{ capitalPreview.minimumActivePeriodDays }}
{{ 'labels.inputs.days' | translate }}</span
>
<span></span>
</div>
}
</div>
}
<mat-form-field>
@@ -57,28 +57,11 @@
}
}
.dividend-mode {
/* Sits under the rate field, saying what the number just typed applies to. */
.dividend-mode__hint {
margin: 0 0 12px;
&__label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: #344054;
}
mat-radio-group {
display: flex;
flex-direction: column;
gap: 2px;
}
&__hint {
margin: 6px 0 0;
font-size: 12px;
color: #667085;
}
font-size: 12px;
color: #667085;
}
.dividend-profit-note {
@@ -10,7 +10,6 @@
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { DecimalPipe } from '@angular/common';
import { Dates } from 'app/core/utils/dates';
@@ -29,8 +28,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./create-dividend.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatRadioGroup,
MatRadioButton,
DecimalPipe
]
})
@@ -53,9 +50,10 @@ export class CreateDividendComponent implements OnInit {
maxDate = new Date();
/**
* The full appropriation waterfall for the selected period, from the backend: net profit (posted
* GL journal entries), less the statutory appropriations that legally precede a dividend, less
* dividends already declared for other share products over an overlapping period.
* The profit available to fund a dividend for the selected period, from the backend: the profit
* standing in the GL, less dividends already declared for other share products over an
* overlapping period. The statutory reserve and education fund are not part of this — they are
* appropriated on the profit-distribution screen before a dividend is ever declared.
*/
profitBreakdown: {
financialYear: string;
@@ -64,10 +62,6 @@ export class CreateDividendComponent implements OnInit {
yearClosed: boolean;
profitSource: 'BANK_PROFIT_GL' | 'OPEN_PROFIT_AND_LOSS';
netProfit: number;
statutoryReservePercentage: number;
statutoryReserveAmount: number;
educationFundPercentage: number;
educationFundAmount: number;
distributableProfit: number;
alreadyDeclared: number;
availableForDeclaration: number;
@@ -85,8 +79,8 @@ export class CreateDividendComponent implements OnInit {
profitLookupError = false;
/**
* Weighted paid-up capital for the period, for RATE_ON_CAPITAL mode. `requiredAmount` is what the
* entered rate actually costs to honour.
* Weighted paid-up capital for the period — the only thing the dividend is calculated from.
* `requiredAmount` is what the entered rate actually costs to honour.
*/
capitalPreview: {
weightedCapital: number;
@@ -98,13 +92,15 @@ export class CreateDividendComponent implements OnInit {
capitalLookupError = false;
/**
* RATE_ON_CAPITAL is the default because it is what a dividend rate means in an AGM resolution:
* each member receives the declared rate on their own paid-up capital, pro-rated for how long
* they held it. PERCENT_OF_PROFIT sizes the pool as a share of profit instead — legitimate when
* the committee decides "distribute this much of the profit", but the resulting per-member rate
* then depends on how many other members hold shares, so it is not the rate members are promised.
* A dividend is a rate on each member's own share investment, and nothing else.
*
* This is what a rate means in an AGM resolution: each member receives the declared rate on their
* own paid-up capital, pro-rated for how long they held it. Sizing the pool as a share of profit
* instead was dropped — the resulting per-member rate depends on how many other members hold
* shares, so it is not the rate members are promised. Profit no longer sizes the dividend; it
* only funds it, which is what {@link ceilingError} checks.
*/
dividendMode: 'RATE_ON_CAPITAL' | 'PERCENT_OF_PROFIT' = 'RATE_ON_CAPITAL';
readonly dividendMode = 'RATE_ON_CAPITAL';
/**
* Financial years a dividend can be declared for, newest first.
@@ -302,10 +298,6 @@ export class CreateDividendComponent implements OnInit {
yearClosed: !!response.yearClosed,
profitSource: response.profitSource,
netProfit: Number(response.netProfit) || 0,
statutoryReservePercentage: Number(response.statutoryReservePercentage) || 0,
statutoryReserveAmount: Number(response.statutoryReserveAmount) || 0,
educationFundPercentage: Number(response.educationFundPercentage) || 0,
educationFundAmount: Number(response.educationFundAmount) || 0,
distributableProfit: Number(response.distributableProfit) || 0,
alreadyDeclared: Number(response.alreadyDeclared) || 0,
availableForDeclaration: Number(response.availableForDeclaration) || 0,
@@ -358,22 +350,13 @@ export class CreateDividendComponent implements OnInit {
});
}
/** Switches between the two ways of sizing the pool and recomputes under the new one. */
onModeChange(mode: 'RATE_ON_CAPITAL' | 'PERCENT_OF_PROFIT'): void {
this.dividendMode = mode;
this.recomputeDividendAmount();
}
/**
* Computes the dividend amount under whichever mode is selected, then checks it against both
* ceilings — the rate cap in the governing Act, and the profit still available to distribute.
* Computes the dividend amount — weighted paid-up capital x rate — then checks it against both
* ceilings: the rate cap in the governing Act, and the profit still available to fund it.
*
* RATE_ON_CAPITAL: amount = weighted paid-up capital x rate. Every member then receives that rate
* on their own capital, pro-rated by holding period.
*
* PERCENT_OF_PROFIT: amount = distributable profit x percentage. Note this is applied to the
* DISTRIBUTABLE figure, not raw net profit — the statutory appropriations come out first either
* way.
* Every member receives the declared rate on their own capital, pro-rated by holding period, so
* the amount follows entirely from what members invested. Profit does not enter the arithmetic;
* it only has to be large enough to pay the result.
*/
recomputeDividendAmount(): void {
const percentage = Number(this.createDividendForm.get('dividendPercentage').value);
@@ -388,36 +371,27 @@ export class CreateDividendComponent implements OnInit {
return;
}
// The rate cap governs a rate promised on capital. Under PERCENT_OF_PROFIT the number entered
// is a share of profit, not a rate any member receives, so the cap does not apply to it.
// The rate cap governs the rate promised on capital, which is exactly what is entered here.
const maximumRate = this.profitBreakdown?.maximumRatePercentage ?? 0;
if (this.dividendMode === 'RATE_ON_CAPITAL' && maximumRate > 0 && percentage > maximumRate) {
if (maximumRate > 0 && percentage > maximumRate) {
this.rateCeilingError = `The maximum dividend rate permitted is ${maximumRate}%. Reduce the rate to declare this dividend.`;
}
let amount: number | null = null;
if (this.dividendMode === 'RATE_ON_CAPITAL') {
if (this.capitalPreview === null) {
this.createDividendForm.get('dividendAmount').setValue('');
return;
}
amount = (this.capitalPreview.weightedCapital * percentage) / 100;
this.capitalPreview.requiredAmount = Math.round(amount * 100) / 100;
} else {
if (this.profitBreakdown === null) {
this.createDividendForm.get('dividendAmount').setValue('');
return;
}
amount = (this.profitBreakdown.distributableProfit * percentage) / 100;
if (this.capitalPreview === null) {
this.createDividendForm.get('dividendAmount').setValue('');
return;
}
const amount = (this.capitalPreview.weightedCapital * percentage) / 100;
const rounded = Math.round(amount * 100) / 100;
this.capitalPreview.requiredAmount = rounded;
this.createDividendForm.get('dividendAmount').setValue(rounded);
// Not a limit on the calculation — a limit on what the profit account can fund. The amount is
// already fixed by capital; this only says whether there is enough profit to pay it.
if (this.profitBreakdown && rounded > this.profitBreakdown.availableForDeclaration) {
this.ceilingError =
`This dividend of ${rounded.toFixed(2)} exceeds the ${this.profitBreakdown.availableForDeclaration.toFixed(2)} ` +
`still available to distribute for this period. Reduce the rate, or declare for a period with more profit.`;
`still available to fund it for this period. Reduce the rate, or declare for a period with more profit.`;
}
}
@@ -17,18 +17,34 @@
>
</div>
<div class="vd-header__actions">
<!-- Standalone, this is a read-only breakdown and the button hands off to the Approval
Centre, so it is labelled for where it goes and needs no approve permission to press.
Embedded, this IS the Approval Centre's breakdown, so it approves in place. -->
@if (!isdividendPosted) {
<button
mat-raised-button
color="primary"
class="vd-approve-btn"
*mifosxHasPermission="'POST_DIVIDENDS'"
(click)="postDividends()"
>
<fa-icon icon="check" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Approve' | translate }}
</button>
@if (embedded) {
<button
mat-raised-button
color="primary"
class="vd-approve-btn"
*mifosxHasPermission="'POST_DIVIDENDS'"
(click)="postDividends()"
>
<fa-icon icon="check" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Approve' | translate }}
</button>
} @else {
<button mat-raised-button color="primary" class="vd-approve-btn" (click)="postDividends()">
<fa-icon icon="arrow-right" class="m-r-10"></fa-icon>
Go to Approval Centre
</button>
}
}
<!-- The distribution report: the heading the board resolved plus the member-wise split it
produced, in one sheet so the two never have to be reconciled by hand. -->
<button mat-stroked-button class="vd-export-btn" [disabled]="exporting" (click)="exportToExcel()">
<fa-icon icon="file-excel" class="m-r-10"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export to Excel' }}
</button>
@if (embedded) {
<button type="button" class="vd-close-btn" (click)="close()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
@@ -38,6 +54,15 @@
</div>
<div class="vd-summary">
<!-- The rate and the payment date are what a distribution report is quoted by — an AGM
resolution says "9% for 2025-26", not "₹11,300" — so they sit alongside the total rather
than being left to be worked out from it. -->
@if (reportHeader?.ratePercentage != null) {
<div class="vd-summary__stat">
<span class="vd-summary__label">{{ 'labels.inputs.Dividend Rate' | translate }}</span>
<span class="vd-summary__value">{{ reportHeader.ratePercentage | number: '1.0-2' }}%</span>
</div>
}
<div class="vd-summary__stat">
<span class="vd-summary__label">{{ 'labels.inputs.Total Amount' | translate }}</span>
<span class="vd-summary__value">{{ totalAmount | number: '1.2-2' }}</span>
@@ -46,6 +71,18 @@
<span class="vd-summary__label">{{ 'labels.inputs.Members' | translate }}</span>
<span class="vd-summary__value">{{ dividendData.pageItems.length }}</span>
</div>
@if (reportHeader?.financialYear) {
<div class="vd-summary__stat">
<span class="vd-summary__label">{{ 'labels.inputs.Financial Year' | translate }}</span>
<span class="vd-summary__value">FY {{ reportHeader.financialYear }}</span>
</div>
}
@if (reportHeader?.paymentDate) {
<div class="vd-summary__stat">
<span class="vd-summary__label">{{ 'labels.inputs.Payment Date' | translate }}</span>
<span class="vd-summary__value">{{ reportHeader.paymentDate | slice: 0 : 10 }}</span>
</div>
}
<div class="vd-summary__stat">
<span class="vd-summary__label">{{ 'labels.inputs.Status' | translate }}</span>
<span [class]="isdividendPosted ? 'vd-badge vd-badge--posted' : 'vd-badge vd-badge--pending'">
@@ -278,3 +278,15 @@ table {
}
}
}
/* Export sits beside the primary action rather than under the table: a clerk asked for the
distribution report wants the file, not the screen, so it belongs where the eye lands first. */
.vd-export-btn {
margin-right: 8px;
color: #1e7f4f;
border-color: #cfe3d8;
&:disabled {
opacity: 0.6;
}
}
@@ -84,6 +84,15 @@ export class ViewDividendComponent implements OnInit {
glTransactionId: string | null;
} | null = null;
/**
* What a distribution report has to state besides the member list: the rate the board resolved
* and the date the dividend became payable. Null for a declaration made before the rate was
* recorded — the heading simply omits what it does not know rather than guessing.
*/
reportHeader: any = null;
exporting = false;
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
displayedColumns: string[] = [
@@ -100,16 +109,34 @@ export class ViewDividendComponent implements OnInit {
.getDividendData(this.overrideProductId, this.overrideDividendId)
.subscribe((dividendData: any) => this.setDividendData(dividendData));
this.loadResidue(this.overrideProductId, this.overrideDividendId);
this.loadReportHeader(this.overrideDividendId);
} else {
this.status = this.route.snapshot.queryParams['status'];
this.route.data.subscribe((data: { dividendData: any }) => this.setDividendData(data.dividendData));
this.loadResidue(
this.route.parent?.parent?.snapshot.paramMap.get('productId'),
this.route.snapshot.paramMap.get('dividendId')
);
const dividendId = this.route.snapshot.paramMap.get('dividendId');
this.loadResidue(this.route.parent?.parent?.snapshot.paramMap.get('productId'), dividendId);
this.loadReportHeader(dividendId);
}
}
/**
* The rate the board resolved and the date the dividend became payable.
*
* Neither is on the member breakdown — a rate is a property of the declaration, and the payment
* date is when it was approved — but a distribution report has to quote both, so they are pulled
* once here. Silently skipped on failure: the breakdown is still perfectly readable without a
* heading, and an error banner over a working page helps nobody.
*/
private loadReportHeader(dividendId: any): void {
if (!dividendId) {
return;
}
this.productsService
.getDividendReportHeader(dividendId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => (this.reportHeader = res));
}
/**
* Declarations made before residue was tracked have no record, and the endpoint returns null for
* them — the panel simply stays hidden rather than showing zeros that would misrepresent them.
@@ -161,27 +188,118 @@ export class ViewDividendComponent implements OnInit {
return 'vd-badge vd-badge--pending';
}
/**
* Approving happens in the Approval Centre, not here.
*
* This page is a read-only breakdown reached from the product's dividend register, and a
* declaration approved from here would bypass the queue every other pending declaration goes
* through — a checker would have no single place that shows what is still outstanding. So the
* button hands off to the Approval Centre with this declaration already open, rather than
* approving in place.
*
* The embedded copy IS the Approval Centre's own breakdown, so there it approves directly.
*/
postDividends() {
const shareProductId = this.embedded
? this.overrideProductId
: this.route.parent.parent.snapshot.paramMap.get('productId');
const dividendId = this.embedded ? this.overrideDividendId : this.route.snapshot.paramMap.get('dividendId');
if (!this.embedded) {
this.router.navigate(
[
'/checker-inbox-and-tasks',
'dividend-requests'
],
{ queryParams: { productId: shareProductId, dividendId: dividendId } }
);
return;
}
// Only the embedded copy reaches here — the standalone page returned above — so this is always
// the Approval Centre approving from its own modal, which closes and refreshes its list.
this.productsService
.approveDividend(shareProductId, dividendId, { productId: shareProductId, dividendId: dividendId })
.subscribe(() => {
if (this.embedded) {
this.actionCompleted.emit();
} else {
// Back to the society-wide dividend register. Not '../', which used to be this product's
// own dividend list — that page is gone now that a dividend is declared for every product
// at once, and relative navigation would land on a route that no longer exists.
this.router.navigate([
'/products',
'share-products',
'dividends'
]);
}
.subscribe(() => this.actionCompleted.emit());
}
/**
* Share Dividend Distribution Report as an Excel workbook.
*
* Two parts in one sheet, because that is what the report is: a heading stating what the board
* resolved — the rate, the amount, the payment date — and beneath it the member-wise distribution
* that heading produced. Split across two files they would have to be reconciled by hand.
*
* Every member is exported, including those whose entitlement rounded to nothing: a distribution
* report that silently omits them cannot be checked against the member register.
*/
async exportToExcel(): Promise<void> {
this.exporting = true;
try {
const ExcelJS = await import('exceljs');
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Dividend Distribution');
const h = this.reportHeader;
const fy = h?.financialYear ?? '';
ws.mergeCells('A1:D1');
ws.getCell('A1').value = 'SHARE DIVIDEND DISTRIBUTION REPORT';
ws.getCell('A1').font = { bold: true, size: 14 };
ws.getCell('A1').alignment = { horizontal: 'center' };
const heading: [string, any][] = [
['Share Product', h?.productName ?? ''],
['Financial Year', fy],
['Dividend Period', h ? `${h.periodStartDate} to ${h.periodEndDate}` : ''],
['Dividend Rate (% per annum)', h?.ratePercentage != null ? Number(h.ratePercentage) : 'Not recorded'],
['Declared Amount', h?.declaredAmount != null ? Number(h.declaredAmount) : ''],
['Distributed to Members', h?.allocatedAmount != null ? Number(h.allocatedAmount) : ''],
['Payment Date', h?.paymentDate ? String(h.paymentDate).slice(0, 10) : 'Not yet approved'],
['Status', h?.status === 'APPROVED' ? 'Approved' : 'Awaiting Approval'],
['Members Paid', h?.paidMembers ?? ''],
['Members Below Minimum', h?.unpaidMembers ?? '']
];
heading.forEach(([label, value], i) => {
const row = ws.getRow(i + 3);
row.getCell(1).value = label;
row.getCell(1).font = { bold: true };
row.getCell(2).value = value;
});
const tableStart = heading.length + 5;
const header = ws.getRow(tableStart);
['Client Name', 'Share Account', 'Dividend Amount', 'Status'].forEach((t, i) => {
header.getCell(i + 1).value = t;
header.getCell(i + 1).font = { bold: true };
header.getCell(i + 1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8ECF0' } };
});
(this.dataSource?.data ?? []).forEach((d: any, i: number) => {
const row = ws.getRow(tableStart + 1 + i);
row.getCell(1).value = d?.accountData?.clientName ?? '';
row.getCell(2).value = d?.accountData?.accountNo ?? '';
row.getCell(3).value = Number(d?.amount) || 0;
row.getCell(3).numFmt = '#,##0.00';
row.getCell(4).value = d?.status?.value ?? '';
});
const totalRow = ws.getRow(tableStart + 1 + (this.dataSource?.data?.length ?? 0));
totalRow.getCell(2).value = 'Total';
totalRow.getCell(2).font = { bold: true };
totalRow.getCell(3).value = (this.dataSource?.data ?? []).reduce((s: number, d: any) => s + (Number(d?.amount) || 0), 0);
totalRow.getCell(3).numFmt = '#,##0.00';
totalRow.getCell(3).font = { bold: true };
ws.getColumn(1).width = 34;
ws.getColumn(2).width = 20;
ws.getColumn(3).width = 18;
ws.getColumn(4).width = 20;
const { downloadWorkbook } = await import('../../../rbi-circulars/oss-workbook');
await downloadWorkbook(wb, `Dividend Distribution Report${fy ? ' - FY ' + fy : ''}.xlsx`);
} finally {
this.exporting = false;
}
}
close(): void {