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
@@ -0,0 +1,29 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { SavingsDailyInterestComponent } from './savings-daily-interest.component';
const routes: Routes = [
Route.withShell([
{
path: 'savings-daily-interest',
component: SavingsDailyInterestComponent,
data: { title: 'Savings Daily Interest', breadcrumb: 'Savings Daily Interest' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class SavingsDailyInterestRoutingModule {}
@@ -0,0 +1,216 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="sdi-container">
<div class="sdi-header">
<h2>Savings Daily Interest</h2>
<div class="sdi-actions">
<button mat-stroked-button (click)="runReconciliation()" [disabled]="loading">Check</button>
<button mat-stroked-button (click)="runBackfill()" [disabled]="loading">Backfill</button>
<button mat-flat-button color="primary" (click)="runToday()" [disabled]="loading">Run Today</button>
</div>
</div>
@if (message) {
<div class="sdi-message" [class.error]="messageIsError" (click)="dismissMessage()">
{{ message }}
</div>
}
<div class="sdi-tabs">
<button [class.active]="activeTab === 'products'" (click)="backToProducts()">Products</button>
@if (selectedProduct) {
<button [class.active]="activeTab === 'accounts'" (click)="backToAccounts()">
{{ selectedProduct.name }}
</button>
}
@if (selectedAccount) {
<button [class.active]="activeTab === 'ledger'">
{{ selectedAccount.account_no }}
</button>
}
<button [class.active]="activeTab === 'runs'" (click)="loadRuns()">Posting Runs</button>
</div>
@if (loading) {
<div class="sdi-loading">Loading…</div>
}
<!-- Products: one row per savings product, with where it stands in its timetable. -->
@if (activeTab === 'products' && !loading) {
<table class="sdi-table">
<thead>
<tr>
<th>Product</th>
<th>Rate</th>
<th>Posts</th>
<th class="num">Accounts</th>
<th class="num">Accrued This Cycle</th>
<th>Last Posted</th>
<th>Next Posting</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (product of products; track product.id) {
<tr class="clickable" (click)="openProduct(product)">
<td>
<strong>{{ product.name }}</strong>
<span class="sdi-sub">{{ product.short_name }}</span>
</td>
<td>{{ product.rate }}%</td>
<td>{{ postingPeriodLabel(product.posting_period_enum) }}</td>
<td class="num">{{ product.active_accounts }}</td>
<td class="num">{{ product.accrued_this_cycle | number: '1.2-2' }}</td>
<td>{{ formatDate(product.last_posting_date) }}</td>
<td>{{ formatDate(product.next_posting_date) }}</td>
<td>
@if (product.last_run_status) {
<span class="sdi-chip" [class.ok]="product.last_run_status === 'COMPLETED'"
[class.bad]="product.last_run_status === 'FAILED'">
{{ product.last_run_status }}
</span>
} @else {
<span class="sdi-sub">Not run</span>
}
</td>
</tr>
} @empty {
<tr><td colspan="8" class="sdi-empty">No savings products carry a ledger yet.</td></tr>
}
</tbody>
</table>
}
<!-- Accounts inside the selected product, as at the latest day recorded. -->
@if (activeTab === 'accounts' && !loading) {
<table class="sdi-table">
<thead>
<tr>
<th>Account</th>
<th>Client</th>
<th>As At</th>
<th class="num">Opening</th>
<th class="num">Closing</th>
<th class="num">Day Interest</th>
<th class="num">Sum This Cycle</th>
<th>Cycle</th>
<th>Pays On</th>
</tr>
</thead>
<tbody>
@for (account of accounts; track account.savings_account_id) {
<tr class="clickable" (click)="openAccount(account)">
<td><strong>{{ account.account_no }}</strong></td>
<td>{{ account.client_name || '—' }}</td>
<td>{{ formatDate(account.balance_date) }}</td>
<td class="num">{{ account.opening_balance | number: '1.2-2' }}</td>
<td class="num">{{ account.closing_balance | number: '1.2-2' }}</td>
<td class="num" [class.zero]="account.below_min_balance">
{{ account.daily_interest | number: '1.4-4' }}
@if (account.below_min_balance) {
<span class="sdi-flag" title="Closed below the minimum balance, so this day earned nothing">below min</span>
}
</td>
<td class="num accent">{{ account.cycle_cumulative_interest | number: '1.2-2' }}</td>
<td>{{ account.cycle_number }}</td>
<td>{{ formatDate(account.cycle_end_date) }}</td>
</tr>
} @empty {
<tr><td colspan="9" class="sdi-empty">No ledger rows for this product yet.</td></tr>
}
</tbody>
</table>
}
<!-- One account, day by day. The posting row is where the sum is paid and reset. -->
@if (activeTab === 'ledger' && !loading) {
<table class="sdi-table">
<thead>
<tr>
<th>Date</th>
<th class="num">Opening</th>
<th class="num">Closing</th>
<th class="num">Day Interest</th>
<th class="num">Running Sum</th>
<th>Cycle</th>
<th class="num">Posted</th>
</tr>
</thead>
<tbody>
@for (day of ledger; track day.balance_date) {
<tr [class.posting-row]="day.is_posting_day">
<td>
{{ formatDate(day.balance_date) }}
@if (day.is_posting_day) {
<span class="sdi-flag paid">posting day</span>
}
</td>
<td class="num">{{ day.opening_balance | number: '1.2-2' }}</td>
<td class="num">{{ day.closing_balance | number: '1.2-2' }}</td>
<td class="num" [class.zero]="day.below_min_balance">
{{ day.daily_interest | number: '1.4-4' }}
@if (day.below_min_balance) {
<span class="sdi-flag" title="Closed below the minimum balance, so this day earned nothing">below min</span>
}
</td>
<td class="num accent">{{ day.cycle_cumulative_interest | number: '1.4-4' }}</td>
<td>{{ day.cycle_number }}</td>
<td class="num">
@if (day.posted_amount !== null) {
<strong>{{ day.posted_amount | number: '1.2-2' }}</strong>
} @else {
—
}
</td>
</tr>
} @empty {
<tr><td colspan="7" class="sdi-empty">No days recorded for this account.</td></tr>
}
</tbody>
</table>
}
<!-- What each run pooled and paid. Residual is the paise rounding moved, and must be accounted for. -->
@if (activeTab === 'runs' && !loading) {
<table class="sdi-table">
<thead>
<tr>
<th>Product</th>
<th>Posting Date</th>
<th class="num">Accounts</th>
<th class="num">Pooled</th>
<th class="num">Distributed</th>
<th class="num">Residual</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (run of runs; track run.id) {
<tr>
<td>{{ run.product_name }}</td>
<td>{{ formatDate(run.posting_date) }}</td>
<td class="num">{{ run.account_count }}</td>
<td class="num">{{ run.pooled_amount | number: '1.2-2' }}</td>
<td class="num">{{ run.distributed_amount | number: '1.2-2' }}</td>
<td class="num">{{ run.rounding_residual | number: '1.2-2' }}</td>
<td>
<span class="sdi-chip" [class.ok]="run.status === 'COMPLETED'"
[class.bad]="run.status === 'FAILED'">{{ run.status }}</span>
@if (run.failure_reason) {
<div class="sdi-sub">{{ run.failure_reason }}</div>
}
</td>
</tr>
} @empty {
<tr><td colspan="7" class="sdi-empty">No posting runs yet.</td></tr>
}
</tbody>
</table>
}
</div>
@@ -0,0 +1,173 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.sdi-container {
padding: 1rem;
}
.sdi-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
}
.sdi-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.sdi-message {
padding: 0.6rem 0.9rem;
margin-bottom: 1rem;
border-radius: 4px;
cursor: pointer;
background: #e8f4ea;
border-left: 4px solid #2e7d32;
font-size: 0.9rem;
&.error {
background: #fdecea;
border-left-color: #c62828;
}
}
.sdi-tabs {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
margin-bottom: 1rem;
button {
background: none;
border: none;
padding: 0.6rem 1rem;
cursor: pointer;
font-size: 0.9rem;
border-bottom: 2px solid transparent;
&.active {
border-bottom-color: #1565c0;
font-weight: 600;
}
}
}
.sdi-loading {
padding: 2rem;
text-align: center;
opacity: 0.7;
}
// Wide tables scroll inside their own box rather than pushing the page sideways.
.sdi-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
display: block;
overflow-x: auto;
white-space: nowrap;
th,
td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
th {
font-weight: 600;
background: rgba(0, 0, 0, 0.03);
position: sticky;
top: 0;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
tr.clickable {
cursor: pointer;
&:hover {
background: rgba(21, 101, 192, 0.06);
}
}
// The day the cycle is paid out and the sum goes back to zero.
tr.posting-row {
background: rgba(46, 125, 50, 0.08);
font-weight: 600;
}
}
.accent {
color: #1565c0;
font-weight: 600;
}
// A day that earned nothing because the balance sat under the product minimum.
.zero {
opacity: 0.55;
}
.sdi-sub {
display: block;
font-size: 0.75rem;
opacity: 0.65;
}
.sdi-empty {
text-align: center;
padding: 2rem;
opacity: 0.6;
}
.sdi-chip {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
background: rgba(0, 0, 0, 0.08);
&.ok {
background: rgba(46, 125, 50, 0.15);
color: #2e7d32;
}
&.bad {
background: rgba(198, 40, 40, 0.15);
color: #c62828;
}
}
.sdi-flag {
display: inline-block;
margin-left: 0.4rem;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 500;
background: rgba(0, 0, 0, 0.08);
&.paid {
background: rgba(46, 125, 50, 0.2);
color: #2e7d32;
}
}
@@ -0,0 +1,213 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import {
SavingsDailyInterestService,
SavingsInterestAccount,
SavingsInterestDay,
SavingsInterestProduct,
SavingsInterestRun
} from './savings-daily-interest.service';
/**
* The savings daily interest ledger.
*
* Interest has always been a single number on the account screen with nothing behind it. This
* shows the working: pick a product, see its accounts and where each stands in its cycle, then
* open one account and read it day by day - what the balance was, what that day earned, and the
* running total climbing until the posting day pays it out and resets it to zero.
*/
@Component({
selector: 'mifosx-savings-daily-interest',
templateUrl: './savings-daily-interest.component.html',
styleUrls: ['./savings-daily-interest.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class SavingsDailyInterestComponent implements OnInit {
private service = inject(SavingsDailyInterestService);
activeTab: 'products' | 'accounts' | 'ledger' | 'runs' = 'products';
products: SavingsInterestProduct[] = [];
accounts: SavingsInterestAccount[] = [];
ledger: SavingsInterestDay[] = [];
runs: SavingsInterestRun[] = [];
selectedProduct: SavingsInterestProduct | null = null;
selectedAccount: SavingsInterestAccount | null = null;
loading = false;
message = '';
messageIsError = false;
ngOnInit(): void {
this.loadProducts();
}
// -------------------- loading --------------------
loadProducts(): void {
this.loading = true;
this.service.getProducts().subscribe({
next: (data) => {
this.products = data ?? [];
this.loading = false;
},
error: (err) => this.fail(err)
});
}
openProduct(product: SavingsInterestProduct): void {
this.selectedProduct = product;
this.selectedAccount = null;
this.activeTab = 'accounts';
this.loading = true;
this.service.getAccountsForProduct(product.id).subscribe({
next: (data) => {
this.accounts = data ?? [];
this.loading = false;
},
error: (err) => this.fail(err)
});
}
openAccount(account: SavingsInterestAccount): void {
this.selectedAccount = account;
this.activeTab = 'ledger';
this.loading = true;
this.service.getAccountLedger(account.savings_account_id).subscribe({
next: (data) => {
this.ledger = data ?? [];
this.loading = false;
},
error: (err) => this.fail(err)
});
}
loadRuns(): void {
this.activeTab = 'runs';
this.loading = true;
this.service.getRuns().subscribe({
next: (data) => {
this.runs = data ?? [];
this.loading = false;
},
error: (err) => this.fail(err)
});
}
// -------------------- actions --------------------
/**
* The chain check and the unposted-total check. Nothing found is the answer you want: every day
* picks up where the last one left off, and the ledger still agrees with the accounts.
*/
runReconciliation(): void {
this.loading = true;
this.service.reconcile().subscribe({
next: (result) => {
this.loading = false;
this.messageIsError = !result.clean;
this.message = result.clean
? 'Reconciliation clean: the balance chain is unbroken and every cycle agrees with its account.'
: `${result.problemCount} problem(s) found: ${result.problems.slice(0, 3).join(' | ')}`;
},
error: (err) => this.fail(err)
});
}
runToday(): void {
this.loading = true;
this.service.runForDate().subscribe({
next: (result) => {
this.loading = false;
this.messageIsError = false;
this.message = `Run completed for ${result?.ranFor ?? 'today'}.`;
this.loadProducts();
},
error: (err) => this.fail(err)
});
}
runBackfill(): void {
this.loading = true;
this.service.backfill().subscribe({
next: (result) => {
this.loading = false;
this.messageIsError = false;
this.message = `Backfill wrote ${result?.rowsWritten ?? 0} rows up to ${result?.upTo ?? 'today'}.`;
this.loadProducts();
},
error: (err) => this.fail(err)
});
}
// -------------------- display helpers --------------------
/** Fineract returns dates as [year, month, day]; month is 1-based. */
formatDate(value: number[] | null | undefined): string {
if (!value || value.length < 3) {
return '—';
}
const [year, month, day] = value;
return `${String(day).padStart(2, '0')}/${String(month).padStart(2, '0')}/${year}`;
}
postingPeriodLabel(value: number): string {
switch (value) {
case 1:
return 'Daily';
case 4:
return 'Monthly';
case 5:
return 'Quarterly';
case 6:
return 'Half-Yearly';
case 7:
return 'Annual';
default:
return 'Not set';
}
}
/**
* Accounts can carry a posting period of their own that differs from the product they were
* opened under, and the engine follows the account. Worth showing rather than hiding: a
* quarterly product whose accounts all post daily is almost never what was intended.
*/
productPostingMismatch(product: SavingsInterestProduct): boolean {
return this.accounts.length > 0 && this.selectedProduct?.id === product.id;
}
backToProducts(): void {
this.activeTab = 'products';
this.selectedProduct = null;
this.selectedAccount = null;
}
backToAccounts(): void {
this.activeTab = 'accounts';
this.selectedAccount = null;
}
dismissMessage(): void {
this.message = '';
}
private fail(error: any): void {
this.loading = false;
this.messageIsError = true;
this.message = error?.error?.defaultUserMessage ?? error?.message ?? 'Request failed.';
}
}
@@ -0,0 +1,22 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { SavingsDailyInterestRoutingModule } from './savings-daily-interest-routing.module';
import { SavingsDailyInterestComponent } from './savings-daily-interest.component';
@NgModule({
imports: [
SharedModule,
SavingsDailyInterestRoutingModule,
SavingsDailyInterestComponent
]
})
export class SavingsDailyInterestModule {}
@@ -0,0 +1,137 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** A savings product and where it stands in its interest posting timetable. */
export interface SavingsInterestProduct {
id: number;
name: string;
short_name: string;
rate: number;
posting_period_enum: number;
run_at_time: string;
last_posting_date: number[] | null;
next_posting_date: number[] | null;
last_run_status: string | null;
is_active: boolean;
active_accounts: number;
accrued_this_cycle: number;
}
/** One account's position in the product ledger, as at the latest day recorded. */
export interface SavingsInterestAccount {
savings_account_id: number;
account_no: string;
client_name: string;
balance_date: number[];
opening_balance: number;
closing_balance: number;
daily_interest: number;
cycle_cumulative_interest: number;
cycle_number: number;
cycle_start_date: number[];
cycle_end_date: number[];
below_min_balance: boolean;
}
/** One day of one account: the working behind that day's interest. */
export interface SavingsInterestDay {
balance_date: number[];
opening_balance: number;
closing_balance: number;
daily_interest: number;
cycle_cumulative_interest: number;
cycle_number: number;
cycle_start_date: number[];
cycle_end_date: number[];
is_posting_day: boolean;
posted_amount: number | null;
savings_transaction_id: number | null;
below_min_balance: boolean;
interest_rate: number;
days_in_year: number;
min_balance_for_interest: number | null;
last_recomputed_on: number[] | null;
}
/** What a posting run pooled and paid, and whether it finished. */
export interface SavingsInterestRun {
id: number;
savings_product_id: number;
product_name: string;
posting_date: number[];
account_count: number;
pooled_amount: number;
distributed_amount: number;
rounding_residual: number;
status: string;
failure_reason: string | null;
started_at: number[] | null;
completed_at: number[] | null;
}
export interface SavingsInterestReconciliation {
clean: boolean;
problemCount: number;
problems: string[];
}
@Injectable({
providedIn: 'root'
})
export class SavingsDailyInterestService {
private http = inject(HttpClient);
private readonly base = '/savingsdailyinterest';
getProducts(): Observable<SavingsInterestProduct[]> {
return this.http.get<SavingsInterestProduct[]>(`${this.base}/products`);
}
getAccountsForProduct(productId: number): Observable<SavingsInterestAccount[]> {
return this.http.get<SavingsInterestAccount[]>(`${this.base}/products/${productId}/accounts`);
}
getAccountLedger(accountId: number, limit = 400): Observable<SavingsInterestDay[]> {
const params = new HttpParams().set('limit', limit);
return this.http.get<SavingsInterestDay[]>(`${this.base}/accounts/${accountId}`, { params });
}
getRuns(productId?: number): Observable<SavingsInterestRun[]> {
let params = new HttpParams();
if (productId) {
params = params.set('productId', productId);
}
return this.http.get<SavingsInterestRun[]>(`${this.base}/runs`, { params });
}
reconcile(): Observable<SavingsInterestReconciliation> {
return this.http.get<SavingsInterestReconciliation>(`${this.base}/reconcile`);
}
/** Runs a day by hand: accrual for every account, then any product whose cycle ends that day. */
runForDate(date?: string): Observable<any> {
let params = new HttpParams();
if (date) {
params = params.set('date', date);
}
return this.http.post(`${this.base}/run`, {}, { params });
}
/** Rebuilds the ledger for every account from activation up to the given date. */
backfill(upTo?: string): Observable<any> {
let params = new HttpParams();
if (upTo) {
params = params.set('upTo', upTo);
}
return this.http.post(`${this.base}/backfill`, {}, { params });
}
}