feat: cash-only registers, day book sections and DD amendments
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-09-03 18:26:22 +05:30
parent 6442562df0
commit fbce73fd17
111 changed files with 9076 additions and 3507 deletions
@@ -24,6 +24,7 @@
<div class="sdi-tabs">
<button [class.active]="activeTab === 'products'" (click)="backToProducts()">Products</button>
<button [class.active]="activeTab === 'summary'" (click)="loadSummary()">All Accounts</button>
@if (selectedProduct) {
<button [class.active]="activeTab === 'accounts'" (click)="backToAccounts()">
{{ selectedProduct.name }}
@@ -41,6 +42,89 @@
<div class="sdi-loading">Loading…</div>
}
<!--
All Accounts: one line per savings account, across every product.
The ledger behind this holds a row per account per day — interest is worked out on the balance
each day carried, and a reversed backdated entry means every day after it is recomputed. This
reads the top of that ledger, so the whole book is one row each.
-->
@if (activeTab === 'summary' && !loading) {
<div class="sdi-summary-bar">
<input
class="sdi-search"
type="text"
[(ngModel)]="summarySearch"
placeholder="Search account number, customer or product"
/>
<span class="sdi-spacer"></span>
<span class="sdi-total">
{{ filteredSummary.length }} accounts · accrued this cycle
<strong>{{ summaryAccruedTotal | number: '1.2-2' }}</strong> · posted to date
<strong>{{ summaryPostedTotal | number: '1.2-2' }}</strong>
</span>
</div>
@if (filteredSummary.length === 0) {
<div class="sdi-loading">
No accounts in the ledger yet. Run the accrual, or use Backfill to rebuild it from activation.
</div>
} @else {
<table class="sdi-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Product</th>
<th>As On</th>
<th class="num">Balance</th>
<th class="num">Rate</th>
<th class="num">Interest Today</th>
<th>Cycle</th>
<th class="num">Accrued This Cycle</th>
<th>Next Posting</th>
<th class="num">Posted To Date</th>
<th></th>
</tr>
</thead>
<tbody>
@for (row of filteredSummary; track row.savings_account_id) {
<tr [class.sdi-row--posting]="row.is_posting_day" [class.sdi-row--below]="row.below_min_balance">
<td class="mono">{{ row.account_no }}</td>
<td>{{ row.client_name || '—' }}</td>
<td>
{{ row.product_name }}
<div class="sdi-sub">{{ postingPeriodLabel(row.posting_period_enum ?? 0) }} posting</div>
</td>
<td class="mono">{{ formatDate(row.as_on_date) }}</td>
<td class="num">{{ row.closing_balance | number: '1.2-2' }}</td>
<td class="num">{{ row.interest_rate | number: '1.2-2' }}%</td>
<td class="num">{{ row.interest_today | number: '1.4-4' }}</td>
<td>
#{{ row.cycle_number }}
<div class="sdi-sub mono">{{ formatDate(row.cycle_start_date) }}</div>
</td>
<td class="num strong">{{ row.interest_this_cycle | number: '1.2-2' }}</td>
<td>
<span class="mono">{{ formatDate(row.cycle_end_date) }}</span>
@if (row.is_posting_day) {
<div class="sdi-sub sdi-sub--due">posting today</div>
} @else {
<div class="sdi-sub">in {{ row.days_to_posting }} days</div>
}
</td>
<td class="num">{{ row.interest_posted_total | number: '1.2-2' }}</td>
<td>
<!-- The day-by-day working, for the account whose figure is being questioned. -->
<button class="sdi-link" (click)="openLedgerFor(row)">Days</button>
</td>
</tr>
}
</tbody>
</table>
}
}
<!-- Products: one row per savings product, with where it stands in its timetable. -->
@if (activeTab === 'products' && !loading) {
<table class="sdi-table">
@@ -71,8 +155,11 @@
<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'">
<span
class="sdi-chip"
[class.ok]="product.last_run_status === 'COMPLETED'"
[class.bad]="product.last_run_status === 'FAILED'"
>
{{ product.last_run_status }}
</span>
} @else {
@@ -81,7 +168,9 @@
</td>
</tr>
} @empty {
<tr><td colspan="8" class="sdi-empty">No savings products carry a ledger yet.</td></tr>
<tr>
<td colspan="8" class="sdi-empty">No savings products carry a ledger yet.</td>
</tr>
}
</tbody>
</table>
@@ -106,7 +195,9 @@
<tbody>
@for (account of accounts; track account.savings_account_id) {
<tr class="clickable" (click)="openAccount(account)">
<td><strong>{{ account.account_no }}</strong></td>
<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>
@@ -114,7 +205,9 @@
<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>
<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>
@@ -122,7 +215,9 @@
<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>
<tr>
<td colspan="9" class="sdi-empty">No ledger rows for this product yet.</td>
</tr>
}
</tbody>
</table>
@@ -156,7 +251,9 @@
<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>
<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>
@@ -170,7 +267,9 @@
</td>
</tr>
} @empty {
<tr><td colspan="7" class="sdi-empty">No days recorded for this account.</td></tr>
<tr>
<td colspan="7" class="sdi-empty">No days recorded for this account.</td>
</tr>
}
</tbody>
</table>
@@ -200,15 +299,18 @@
<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>
<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>
<tr>
<td colspan="7" class="sdi-empty">No posting runs yet.</td>
</tr>
}
</tbody>
</table>
@@ -50,7 +50,7 @@
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
border-bottom: 1px solid rgb(0 0 0 / 12%);
margin-bottom: 1rem;
button {
@@ -87,12 +87,12 @@
td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
border-bottom: 1px solid rgb(0 0 0 / 8%);
}
th {
font-weight: 600;
background: rgba(0, 0, 0, 0.03);
background: rgb(0 0 0 / 3%);
position: sticky;
top: 0;
}
@@ -106,13 +106,13 @@
cursor: pointer;
&:hover {
background: rgba(21, 101, 192, 0.06);
background: rgb(21 101 192 / 6%);
}
}
// The day the cycle is paid out and the sum goes back to zero.
tr.posting-row {
background: rgba(46, 125, 50, 0.08);
background: rgb(46 125 50 / 8%);
font-weight: 600;
}
}
@@ -144,15 +144,15 @@
padding: 0.15rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
background: rgba(0, 0, 0, 0.08);
background: rgb(0 0 0 / 8%);
&.ok {
background: rgba(46, 125, 50, 0.15);
background: rgb(46 125 50 / 15%);
color: #2e7d32;
}
&.bad {
background: rgba(198, 40, 40, 0.15);
background: rgb(198 40 40 / 15%);
color: #c62828;
}
}
@@ -164,10 +164,74 @@
border-radius: 3px;
font-size: 0.7rem;
font-weight: 500;
background: rgba(0, 0, 0, 0.08);
background: rgb(0 0 0 / 8%);
&.paid {
background: rgba(46, 125, 50, 0.2);
background: rgb(46 125 50 / 20%);
color: #2e7d32;
}
}
// ── All Accounts: one line per account ───────────────────────────────────────
.sdi-summary-bar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.sdi-search {
min-width: 20rem;
padding: 0.45rem 0.7rem;
border: 1px solid rgb(0 0 0 / 20%);
border-radius: 4px;
font: inherit;
font-size: 0.85rem;
}
.sdi-spacer {
flex: 1;
}
.sdi-total {
font-size: 0.8rem;
opacity: 0.75;
}
.mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.8rem;
}
.strong {
font-weight: 700;
}
// The day the cycle is paid out — the accrued figure goes back to zero after it.
tr.sdi-row--posting {
background: rgb(46 125 50 / 8%);
}
// Under the product minimum, so this day earns nothing at all.
tr.sdi-row--below {
opacity: 0.6;
}
.sdi-sub--due {
color: #2e7d32;
font-weight: 700;
opacity: 1;
}
.sdi-link {
border: 0;
background: none;
padding: 0;
font: inherit;
font-size: 0.8rem;
color: #1565c0;
text-decoration: underline;
cursor: pointer;
}
@@ -14,7 +14,8 @@ import {
SavingsInterestAccount,
SavingsInterestDay,
SavingsInterestProduct,
SavingsInterestRun
SavingsInterestRun,
SavingsInterestSummaryRow
} from './savings-daily-interest.service';
/**
@@ -37,13 +38,17 @@ import {
export class SavingsDailyInterestComponent implements OnInit {
private service = inject(SavingsDailyInterestService);
activeTab: 'products' | 'accounts' | 'ledger' | 'runs' = 'products';
activeTab: 'products' | 'summary' | 'accounts' | 'ledger' | 'runs' = 'products';
products: SavingsInterestProduct[] = [];
accounts: SavingsInterestAccount[] = [];
ledger: SavingsInterestDay[] = [];
runs: SavingsInterestRun[] = [];
/** One row per account across every product. */
summary: SavingsInterestSummaryRow[] = [];
summarySearch = '';
selectedProduct: SavingsInterestProduct | null = null;
selectedAccount: SavingsInterestAccount | null = null;
@@ -95,6 +100,39 @@ export class SavingsDailyInterestComponent implements OnInit {
});
}
/**
* Opens the day-by-day working for a row on the All Accounts list.
*
* The summary row carries what the ledger tab needs to caption itself, so it is mapped across
* rather than the account being re-fetched: the two views read the same ledger, and a second call
* would only be able to disagree with the row the user just clicked.
*/
openLedgerFor(row: SavingsInterestSummaryRow): void {
this.selectedAccount = {
savings_account_id: row.savings_account_id,
account_no: row.account_no,
client_name: row.client_name ?? '',
balance_date: row.as_on_date,
opening_balance: row.opening_balance,
closing_balance: row.closing_balance,
daily_interest: row.interest_today,
cycle_cumulative_interest: row.interest_this_cycle,
cycle_number: row.cycle_number,
cycle_start_date: row.cycle_start_date,
cycle_end_date: row.cycle_end_date,
below_min_balance: row.below_min_balance
};
this.activeTab = 'ledger';
this.loading = true;
this.service.getAccountLedger(row.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;
@@ -107,6 +145,43 @@ export class SavingsDailyInterestComponent implements OnInit {
});
}
/**
* Every account on one line: today's balance and interest, the cycle it is in, and what it has
* been paid.
*
* Loaded unfiltered and narrowed in the browser. The whole book is one row per account, which is
* small enough to hold, and filtering locally means typing in the search box does not re-query.
*/
loadSummary(): void {
this.activeTab = 'summary';
this.loading = true;
this.service.getSummary().subscribe({
next: (data) => {
this.summary = data ?? [];
this.loading = false;
},
error: (err) => this.fail(err)
});
}
get filteredSummary(): SavingsInterestSummaryRow[] {
const needle = this.summarySearch.trim().toLowerCase();
if (!needle) {
return this.summary;
}
return this.summary.filter((r) =>
`${r.account_no} ${r.client_name ?? ''} ${r.product_name}`.toLowerCase().includes(needle)
);
}
get summaryAccruedTotal(): number {
return this.filteredSummary.reduce((s, r) => s + Number(r.interest_this_cycle ?? 0), 0);
}
get summaryPostedTotal(): number {
return this.filteredSummary.reduce((s, r) => s + Number(r.interest_posted_total ?? 0), 0);
}
// -------------------- actions --------------------
/**
@@ -160,7 +235,11 @@ export class SavingsDailyInterestComponent implements OnInit {
if (!value || value.length < 3) {
return '—';
}
const [year, month, day] = value;
const [
year,
month,
day
] = value;
return `${String(day).padStart(2, '0')}/${String(month).padStart(2, '0')}/${year}`;
}
@@ -42,6 +42,85 @@ export interface SavingsInterestAccount {
below_min_balance: boolean;
}
/**
* One row per savings account: where that account stands right now.
*
* The ledger behind this holds a row per account per day, because interest is worked out on the
* balance each day carried and a reversed backdated entry means every day after it is recomputed.
* This is the current position read off the top of that ledger — today's figures, the cycle the
* account is in, and what it has been paid so far — so a screen can show one line per account
* without reading a month of rows to find it.
*/
export interface SavingsInterestSummaryRow {
savings_account_id: number;
account_no: string;
client_id: number | null;
client_name: string | null;
office_name: string | null;
savings_product_id: number;
product_name: string;
currency_code: string;
/** The latest day the ledger has accrued for. */
as_on_date: number[];
opening_balance: number;
closing_balance: number;
interest_today: number;
cycle_number: number;
cycle_start_date: number[];
cycle_end_date: number[];
/** Accrued since the cycle began; resets to the day's interest after a posting. */
interest_this_cycle: number;
days_to_posting: number;
is_posting_day: boolean;
posting_date: number[] | null;
interest_rate: number;
days_in_year: number;
compounding_period_enum: number | null;
posting_period_enum: number | null;
calculation_type_enum: number | null;
min_balance_for_interest: number | null;
below_min_balance: boolean;
/** Everything ever posted to the account from this ledger. */
interest_posted_total: number;
last_posted_on: number[] | null;
/** How many days the ledger holds for the account. */
days_accrued: number;
last_recomputed_on: string | null;
}
/**
* One interest posting: what a member was actually paid, and on what.
*
* Read from the savings transaction rather than the ledger — the ledger clears its posted amount
* when the next cycle opens, so it cannot answer what was paid last quarter. The transaction is
* the payment and stays.
*/
export interface SavingsInterestPostingRow {
transaction_id: number;
savings_account_id: number;
account_no: string;
client_id: number | null;
client_name: string | null;
office_name: string | null;
savings_product_id: number;
product_name: string;
/** The product's own rhythm: 1 daily, 4 monthly, 5 quarterly, 6 half-yearly, 7 annual. */
posting_period_enum: number;
interest_rate: number;
currency_code: string;
/** ISO yyyy-MM-dd — formatted server-side, because this endpoint serialises with a plain Gson. */
posting_date: string;
interest_amount: number;
/** Balance the interest was credited onto, and what it became. */
balance_before: number;
balance_after: number;
recorded_on: string | null;
}
/** One day of one account: the working behind that day's interest. */
export interface SavingsInterestDay {
balance_date: number[];
@@ -100,6 +179,61 @@ export class SavingsDailyInterestService {
return this.http.get<SavingsInterestAccount[]>(`${this.base}/products/${productId}/accounts`);
}
/**
* One row per account, across every product — the whole book's current interest position.
*
* Narrow it with any of the filters; all three are optional and combine.
*/
getSummary(filters?: {
productId?: number;
officeName?: string;
accountNo?: string;
}): Observable<SavingsInterestSummaryRow[]> {
let params = new HttpParams();
if (filters?.productId) {
params = params.set('productId', filters.productId);
}
if (filters?.officeName) {
params = params.set('officeName', filters.officeName);
}
if (filters?.accountNo) {
params = params.set('accountNo', filters.accountNo);
}
return this.http.get<SavingsInterestSummaryRow[]>(`${this.base}/summary`, { params });
}
/**
* Interest actually paid to members, one row per posting.
*
* `period` narrows to one posting rhythm (1 daily, 4 monthly, 5 quarterly, 6 half-yearly,
* 7 annual), matching the product's own posting period.
*/
getPostings(filters?: {
period?: number;
productId?: number;
fromDate?: string;
toDate?: string;
officeName?: string;
}): Observable<SavingsInterestPostingRow[]> {
let params = new HttpParams();
if (filters?.period) {
params = params.set('period', filters.period);
}
if (filters?.productId) {
params = params.set('productId', filters.productId);
}
if (filters?.fromDate) {
params = params.set('fromDate', filters.fromDate);
}
if (filters?.toDate) {
params = params.set('toDate', filters.toDate);
}
if (filters?.officeName) {
params = params.set('officeName', filters.officeName);
}
return this.http.get<SavingsInterestPostingRow[]>(`${this.base}/postings`, { params });
}
getAccountLedger(accountId: number, limit = 400): Observable<SavingsInterestDay[]> {
const params = new HttpParams().set('limit', limit);
return this.http.get<SavingsInterestDay[]>(`${this.base}/accounts/${accountId}`, { params });