done
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user