fix: tenant-config fixes, cashier/share/savings UI bugs, and menu-access hardcoding

- Add Balance Sheet report and disabled-features accounting config
- Point menu-access tenant header and env.js/environment.ts tenant identifier
  at env config instead of hardcoded 'default'/'lincpay' literals
- Surface the real backend error (not a generic "no reference id") when an
  OTP send fails in contact-otp-block
- Fix branch-day "₹null" display bug (null vs undefined) and add a guard
  blocking a branch's first-ever day open before cash denomination migration
- Dynamically size the denomination count input and block re-opening an
  already-open business day from Migrate Cash Denomination
- Lock the denomination-managed cash GL row in Migrate Opening Balances
  (read-only, with a link back to Migrate Cash Denomination)
- Remove duplicate Cancel/Submit action bars on the Share Product and
  Savings Account creation preview steps; align Previous into the same row
- Hide "Apply Collection" on a share account once its advance collection
  is already settled (further purchases go through Apply Additional Shares)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 15:19:21 +05:30
co-authored by Claude Sonnet 5
parent 42424dff8b
commit 7b77166b19
39 changed files with 1800 additions and 266 deletions
@@ -72,6 +72,7 @@ import { ViewJournalEntryTransactionComponent } from 'app/shared/accounting/view
import { JournalEntryTransactionResolver } from './common-resolvers/journal-entry-transaction.resolver'; import { JournalEntryTransactionResolver } from './common-resolvers/journal-entry-transaction.resolver';
import { ExternalAssetOwnerJournalEntryResolver } from 'app/loans/common-resolvers/external-asset-owner-journal-entry.resolver'; import { ExternalAssetOwnerJournalEntryResolver } from 'app/loans/common-resolvers/external-asset-owner-journal-entry.resolver';
import { ExternalAssetConfigurationResolver } from './common-resolvers/external-asset-configuration.resolver'; import { ExternalAssetConfigurationResolver } from './common-resolvers/external-asset-configuration.resolver';
import { accountingFeatureEnabledGuard } from './disabled-features';
/** Accounting Routes */ /** Accounting Routes */
const routes: Routes = [ const routes: Routes = [
@@ -99,7 +100,8 @@ const routes: Routes = [
{ {
path: 'frequent-postings', path: 'frequent-postings',
component: FrequentPostingsComponent, component: FrequentPostingsComponent,
data: { title: 'Frequent Postings', breadcrumb: 'Frequent Postings' }, canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Frequent Postings', breadcrumb: 'Frequent Postings', feature: 'frequentPostings' },
resolve: { resolve: {
offices: OfficesResolver, offices: OfficesResolver,
accountingRules: AccountingRulesAssociationsResolver, accountingRules: AccountingRulesAssociationsResolver,
@@ -110,7 +112,8 @@ const routes: Routes = [
{ {
path: 'create', path: 'create',
component: CreateJournalEntryComponent, component: CreateJournalEntryComponent,
data: { title: 'Create Journal Entry', breadcrumb: 'Create' }, canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Create Journal Entry', breadcrumb: 'Create', feature: 'createJournalEntries' },
resolve: { resolve: {
offices: OfficesResolver, offices: OfficesResolver,
currencies: CurrenciesResolver, currencies: CurrenciesResolver,
@@ -194,6 +197,11 @@ const routes: Routes = [
offices: OfficesResolver offices: OfficesResolver
} }
}, },
{
path: 'balance-sheet',
loadComponent: () => import('./balance-sheet/balance-sheet.component').then((c) => c.BalanceSheetComponent),
data: { title: 'Balance Sheet', breadcrumb: 'Balance Sheet' }
},
{ {
path: 'chart-of-accounts', path: 'chart-of-accounts',
data: { title: 'Chart of Accounts', breadcrumb: 'Chart of Accounts' }, data: { title: 'Chart of Accounts', breadcrumb: 'Chart of Accounts' },
@@ -244,7 +252,8 @@ const routes: Routes = [
}, },
{ {
path: 'closing-entries', path: 'closing-entries',
data: { title: 'Accounting Closures', breadcrumb: 'Closing Entries' }, canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Accounting Closures', breadcrumb: 'Closing Entries', feature: 'closingEntries' },
children: [ children: [
{ {
path: '', path: '',
@@ -285,7 +294,8 @@ const routes: Routes = [
}, },
{ {
path: 'accounting-rules', path: 'accounting-rules',
data: { title: 'Accounting Rules', breadcrumb: 'Accounting Rules' }, canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Accounting Rules', breadcrumb: 'Accounting Rules', feature: 'accountingRules' },
children: [ children: [
{ {
path: '', path: '',
@@ -332,11 +342,17 @@ const routes: Routes = [
{ {
path: 'periodic-accruals', path: 'periodic-accruals',
component: PeriodicAccrualsComponent, component: PeriodicAccrualsComponent,
data: { title: 'Periodic Accrual Accounting', breadcrumb: 'Execute Periodic Accrual Accounting' } canActivate: [accountingFeatureEnabledGuard],
data: {
title: 'Periodic Accrual Accounting',
breadcrumb: 'Execute Periodic Accrual Accounting',
feature: 'accruals'
}
}, },
{ {
path: 'provisioning-entries', path: 'provisioning-entries',
data: { title: 'Provisioning Entries', breadcrumb: 'Provisioning Entries' }, canActivate: [accountingFeatureEnabledGuard],
data: { title: 'Provisioning Entries', breadcrumb: 'Provisioning Entries', feature: 'provisioningEntries' },
children: [ children: [
{ {
path: '', path: '',
+32 -7
View File
@@ -17,7 +17,8 @@
<!-- Cards Grid --> <!-- Cards Grid -->
<div class="acc-grid"> <div class="acc-grid">
<!-- Frequent Postings --> <!-- Frequent Postings — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.frequentPostings) {
<a <a
class="acc-card" class="acc-card"
[routerLink]="['journal-entries/frequent-postings']" [routerLink]="['journal-entries/frequent-postings']"
@@ -30,8 +31,10 @@
<span class="acc-card__desc">{{ 'labels.text.These are predefined postings' | translate }}</span> <span class="acc-card__desc">{{ 'labels.text.These are predefined postings' | translate }}</span>
</div> </div>
</a> </a>
}
<!-- Create Journal Entries --> <!-- Create Journal Entries — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.createJournalEntries) {
<a <a
class="acc-card" class="acc-card"
[routerLink]="['journal-entries/create']" [routerLink]="['journal-entries/create']"
@@ -46,6 +49,7 @@
}}</span> }}</span>
</div> </div>
</a> </a>
}
<!-- Search Journal Entries --> <!-- Search Journal Entries -->
<a class="acc-card" [routerLink]="['journal-entries']" *mifosxHasPermission="'READ_JOURNALENTRY'"> <a class="acc-card" [routerLink]="['journal-entries']" *mifosxHasPermission="'READ_JOURNALENTRY'">
@@ -113,7 +117,17 @@
</div> </div>
</a> </a>
<!-- Closing Entries --> <!-- Balance Sheet -->
<a class="acc-card" [routerLink]="['balance-sheet']" *mifosxHasPermission="'READ_GLACCOUNT'" #balanceSheet>
<div class="acc-card__icon teal"><i class="fas fa-balance-scale"></i></div>
<div class="acc-card__info">
<span class="acc-card__title">Balance Sheet</span>
<span class="acc-card__desc">Header/Detail balances as on any date</span>
</div>
</a>
<!-- Closing Entries — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.closingEntries) {
<a class="acc-card" [routerLink]="['closing-entries']" *mifosxHasPermission="'READ_GLCLOSURE'" #closingEntries> <a class="acc-card" [routerLink]="['closing-entries']" *mifosxHasPermission="'READ_GLCLOSURE'" #closingEntries>
<div class="acc-card__icon red"><i class="fas fa-archive"></i></div> <div class="acc-card__icon red"><i class="fas fa-archive"></i></div>
<div class="acc-card__info"> <div class="acc-card__info">
@@ -123,8 +137,10 @@
}}</span> }}</span>
</div> </div>
</a> </a>
}
<!-- Accounting Rules --> <!-- Accounting Rules — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.accountingRules) {
<a class="acc-card" [routerLink]="['accounting-rules']" *mifosxHasPermission="'READ_ACCOUNTINGRULE'"> <a class="acc-card" [routerLink]="['accounting-rules']" *mifosxHasPermission="'READ_ACCOUNTINGRULE'">
<div class="acc-card__icon purple"><i class="fas fa-list-ul"></i></div> <div class="acc-card__icon purple"><i class="fas fa-list-ul"></i></div>
<div class="acc-card__info"> <div class="acc-card__info">
@@ -132,17 +148,25 @@
<span class="acc-card__desc">{{ 'labels.text.List of all accounting rules' | translate }}</span> <span class="acc-card__desc">{{ 'labels.text.List of all accounting rules' | translate }}</span>
</div> </div>
</a> </a>
}
<!-- Accruals --> <!-- Accruals — hidden while disabled, see disabled-features.ts -->
<a class="acc-card" [routerLink]="['periodic-accruals']" *mifosxHasPermission="'EXECUTE_PERIODICACCRUALACCOUNTING'"> @if (!disabledFeatures.accruals) {
<a
class="acc-card"
[routerLink]="['periodic-accruals']"
*mifosxHasPermission="'EXECUTE_PERIODICACCRUALACCOUNTING'"
>
<div class="acc-card__icon green"><i class="fas fa-calendar-check"></i></div> <div class="acc-card__icon green"><i class="fas fa-calendar-check"></i></div>
<div class="acc-card__info"> <div class="acc-card__info">
<span class="acc-card__title">{{ 'labels.heading.Accruals' | translate }}</span> <span class="acc-card__title">{{ 'labels.heading.Accruals' | translate }}</span>
<span class="acc-card__desc">{{ 'labels.text.Accrues income, expenses and liabilities' | translate }}</span> <span class="acc-card__desc">{{ 'labels.text.Accrues income, expenses and liabilities' | translate }}</span>
</div> </div>
</a> </a>
}
<!-- Provisioning Entries --> <!-- Provisioning Entries — hidden while disabled, see disabled-features.ts -->
@if (!disabledFeatures.provisioningEntries) {
<a class="acc-card" [routerLink]="['provisioning-entries']" *mifosxHasPermission="'VIEW_PROVISIONING_ENTRIES'"> <a class="acc-card" [routerLink]="['provisioning-entries']" *mifosxHasPermission="'VIEW_PROVISIONING_ENTRIES'">
<div class="acc-card__icon amber"><i class="fas fa-cogs"></i></div> <div class="acc-card__icon amber"><i class="fas fa-cogs"></i></div>
<div class="acc-card__info"> <div class="acc-card__info">
@@ -150,6 +174,7 @@
<span class="acc-card__desc">{{ 'labels.text.Create provisioning entries' | translate }}</span> <span class="acc-card__desc">{{ 'labels.text.Create provisioning entries' | translate }}</span>
</div> </div>
</a> </a>
}
</div> </div>
</div> </div>
+9 -2
View File
@@ -18,6 +18,7 @@ import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatLine } from '@angular/material/grid-list'; import { MatLine } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DISABLED_ACCOUNTING_FEATURES } from './disabled-features';
/** /**
* Accounting component. * Accounting component.
@@ -63,6 +64,9 @@ export class AccountingComponent implements AfterViewInit {
// Initialize an array of 10 boolean values, all set to false // Initialize an array of 10 boolean values, all set to false
arrowBooleans: boolean[] = new Array(10).fill(false); arrowBooleans: boolean[] = new Array(10).fill(false);
/** Which accounting tiles are switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
/** /**
* Popover function * Popover function
* @param template TemplateRef<any>. * @param template TemplateRef<any>.
@@ -104,12 +108,15 @@ export class AccountingComponent implements AfterViewInit {
); );
}); });
} }
if (this.configurationWizardService.showClosingEntries) { // Closing Entries and Create Journal Entries can be switched off (see disabled-features.ts), in
// which case their tiles never render and these refs stay undefined — the wizard simply has no
// step to point at, rather than throwing on a missing element.
if (this.configurationWizardService.showClosingEntries && this.closingEntries) {
setTimeout(() => { setTimeout(() => {
this.showPopover(this.templateClosingEntries, this.closingEntries.nativeElement, 'bottom', true); this.showPopover(this.templateClosingEntries, this.closingEntries.nativeElement, 'bottom', true);
}); });
} }
if (this.configurationWizardService.showCreateJournalEntries) { if (this.configurationWizardService.showCreateJournalEntries && this.createJournalEntries) {
setTimeout(() => { setTimeout(() => {
this.showPopover(this.templateCreateJournalEntries, this.createJournalEntries.nativeElement, 'bottom', true); this.showPopover(this.templateCreateJournalEntries, this.createJournalEntries.nativeElement, 'bottom', true);
}); });
+16
View File
@@ -116,6 +116,22 @@ export class AccountingService {
return this.http.get(`/glaccounts`, { params: httpParams }); return this.http.get(`/glaccounts`, { params: httpParams });
} }
/**
* As-on-date balance sheet, grouped by Header/Detail hierarchy — see BalanceSheetApiResource.
* @param asOnDate yyyy-MM-dd. Defaults to today on the backend when omitted.
* @param officeId Optional — scopes every balance to one office instead of the whole organization.
*/
getBalanceSheet(asOnDate?: string, officeId?: number): Observable<any> {
let httpParams = new HttpParams();
if (asOnDate) {
httpParams = httpParams.set('asOnDate', asOnDate);
}
if (officeId != null) {
httpParams = httpParams.set('officeId', officeId.toString());
}
return this.http.get('/accounting/balancesheet', { params: httpParams });
}
/** /**
* @param transactionId Transaction ID of journal entries. * @param transactionId Transaction ID of journal entries.
* @returns {Observable<any>} Journal Entries. * @returns {Observable<any>} Journal Entries.
@@ -0,0 +1,363 @@
/**
* 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/.
*/
/*
* Builds the Consolidated Balance Sheet PDF, laid out like the bank's printed report: liabilities
* on the left, assets on the right, each top-level group a named block with its accounts listed
* under it and a subtotal in the TOTAL column.
*
* Drawn directly with jsPDF rather than printed from the DOM: window.print() carries the whole app
* shell (sidenav, toolbar, ticker) into the output and gives no control over where a page breaks,
* which is what made the printed sheet unreadable. Here each column is paginated on its own, the
* way the printed report does it — the left side can run onto page 2 while the right side is still
* on page 1 — and every page repeats the heading.
*/
import { jsPDF } from 'jspdf';
export interface BalanceSheetPdfNode {
name: string;
glCode: string;
usage: 'HEADER' | 'DETAIL';
balance: number;
children: BalanceSheetPdfNode[];
}
export interface BalanceSheetPdfSide {
label: string;
total: number;
groups: { classification: string; total: number; nodes: BalanceSheetPdfNode[] }[];
}
export interface BalanceSheetPdfInput {
bankName: string;
officeLabel: string;
/** yyyy-MM-dd */
asOnDate: string;
creditSide: BalanceSheetPdfSide;
debitSide: BalanceSheetPdfSide;
}
// ── Page geometry, in mm on A4 portrait ─────────────────────────────────────
const PAGE_W = 210;
const PAGE_H = 297;
const MARGIN = 8;
const COL_W = (PAGE_W - MARGIN * 2) / 2;
const COL_X = [
MARGIN,
MARGIN + COL_W
];
/** Right edges of the BALANCE and TOTAL columns, relative to a column's left edge. */
const BALANCE_R = COL_W - 26;
const TOTAL_R = COL_W - 2;
/** Name text wraps within this width so it never runs under the BALANCE column. */
const NAME_W = COL_W - 30;
const BODY_TOP = 36;
/** Leaves room under the body for the sign-off row and the print-date footer. */
const BODY_BOTTOM = PAGE_H - 21;
const LINE_H = 3.1;
const INK: [number, number, number] = [
20,
20,
20
];
const MUTED: [number, number, number] = [
90,
90,
90
];
type Line =
| { kind: 'block'; text: string }
| { kind: 'subhead'; text: string; depth: number }
| { kind: 'detail'; lines: string[]; amount: string; depth: number }
| { kind: 'subsubtotal'; amount: string }
| { kind: 'subtotal'; amount: string }
| { kind: 'standalone'; lines: string[]; amount: string }
| { kind: 'grand'; amount: string }
| { kind: 'gap' };
function money(value: number): string {
return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0);
}
function ddmmyyyy(iso: string): string {
if (!iso) {
return '';
}
const [
y,
m,
d
] = iso.split('-');
return `${d}/${m}/${y}`;
}
function heightOf(line: Line): number {
switch (line.kind) {
case 'block':
// Heading plus the short dashed rule beneath it.
return LINE_H + 1.8;
case 'detail':
case 'standalone':
return LINE_H * line.lines.length;
case 'gap':
return 2.4;
case 'grand':
// Rule above, the figure, and the double rule beneath it.
return LINE_H + 6;
default:
return LINE_H;
}
}
/**
* Flattens one side into the exact sequence of printed lines. Done up front so pagination is a
* matter of measuring heights, with no recursion happening mid-page.
*/
function flatten(doc: jsPDF, side: BalanceSheetPdfSide): Line[] {
const out: Line[] = [];
// splitTextToSize measures with whatever font is currently set, so it has to match the font the
// detail lines are actually drawn in — otherwise names wrap at the wrong width.
doc.setFont('helvetica', 'normal').setFontSize(6.8);
const pushNode = (node: BalanceSheetPdfNode, depth: number): void => {
if (node.usage === 'DETAIL') {
const indent = depth * 2;
const wrapped: string[] = doc.splitTextToSize(node.name, NAME_W - indent);
out.push({ kind: 'detail', lines: wrapped, amount: money(node.balance), depth });
return;
}
out.push({ kind: 'subhead', text: node.name, depth });
node.children.forEach((child) => pushNode(child, depth + 1));
out.push({ kind: 'subsubtotal', amount: money(node.balance) });
};
for (const group of side.groups) {
for (const root of group.nodes) {
if (root.usage === 'DETAIL') {
// A Detail account sitting under no group at all: a group of one, printed on a single line
// carrying both its balance and its total, rather than a heading and a figure on separate
// rows with the BALANCE column left empty.
out.push({
kind: 'standalone',
lines: doc.splitTextToSize(root.name.toUpperCase(), NAME_W),
amount: money(root.balance)
});
out.push({ kind: 'gap' });
continue;
}
out.push({ kind: 'block', text: root.name });
root.children.forEach((child) => pushNode(child, 0));
out.push({ kind: 'subtotal', amount: money(root.balance) });
out.push({ kind: 'gap' });
}
}
// Carried as a line of its own so pagination reserves room for it, rather than squeezing it
// against the bottom margin under the last block.
out.push({ kind: 'grand', amount: money(side.total) });
return out;
}
function dottedLine(doc: jsPDF, x1: number, y: number, x2: number): void {
doc.setLineDashPattern(
[
0.4,
0.6
],
0
);
doc.setLineWidth(0.15);
doc.line(x1, y, x2, y);
doc.setLineDashPattern([], 0);
}
function drawPageFrame(doc: jsPDF, input: BalanceSheetPdfInput, page: number, pageCount: number): void {
doc.setTextColor(...INK);
doc.setFont('helvetica', 'bold').setFontSize(9.5);
doc.text(`${input.bankName.toUpperCase()} , HEAD OFFICE`, PAGE_W / 2, 11, { align: 'center' });
doc.setFontSize(8);
doc.text(`CONSOLIDATED BALANCE SHEET (SUB GROUP WISE) AS ON : ${ddmmyyyy(input.asOnDate)}`, PAGE_W / 2, 16, {
align: 'center'
});
doc.setFont('helvetica', 'normal').setFontSize(7);
doc.text(`Page ${page} of ${pageCount}`, PAGE_W - MARGIN, 16, { align: 'right' });
doc.text(`FOR BRANCHES : ${input.officeLabel}`, PAGE_W / 2, 20, { align: 'center' });
dottedLine(doc, MARGIN, 23, PAGE_W - MARGIN);
// Side titles and column headings, drawn per column.
const titles = [
'LIABILITIES ( CREDIT )',
'ASSETS ( DEBIT )'
];
for (let c = 0; c < 2; c++) {
const x = COL_X[c];
doc.setFont('helvetica', 'bold').setFontSize(7.5);
doc.text(titles[c], x + 1, 27.5);
doc.setFontSize(6.5);
doc.text('ACCOUNT NAME', x + 1, 32);
doc.text('BALANCE', x + BALANCE_R, 32, { align: 'right' });
doc.text('TOTAL', x + TOTAL_R, 32, { align: 'right' });
dottedLine(doc, x + 1, 33.4, x + COL_W - 2);
}
// The divider between the two sides.
doc.setLineWidth(0.15);
doc.setDrawColor(150, 150, 150);
doc.line(COL_X[1], 24, COL_X[1], BODY_BOTTOM + 2);
doc.setDrawColor(0, 0, 0);
// Footer.
dottedLine(doc, MARGIN, BODY_BOTTOM + 6, PAGE_W - MARGIN);
doc
.setFont('helvetica', 'normal')
.setFontSize(6.5)
.setTextColor(...MUTED);
const now = new Date();
const stamp =
`${`${now.getDate()}`.padStart(2, '0')}/${`${now.getMonth() + 1}`.padStart(2, '0')}/${now.getFullYear()} ` +
`${`${now.getHours()}`.padStart(2, '0')}:${`${now.getMinutes()}`.padStart(2, '0')}:${`${now.getSeconds()}`.padStart(2, '0')}`;
doc.text(`Print Date : ${stamp}`, MARGIN, BODY_BOTTOM + 10);
doc.text('Consolidated Balance Sheet (Sub Group Wise)', PAGE_W - MARGIN, BODY_BOTTOM + 10, { align: 'right' });
doc.setTextColor(...INK);
}
/** Draws one line into a column at y, and returns the y the next line starts at. */
function drawLine(doc: jsPDF, line: Line, x: number, y: number): number {
switch (line.kind) {
case 'block': {
doc.setFont('helvetica', 'bold').setFontSize(7);
doc.text(line.text.toUpperCase(), x + 1, y);
dottedLine(doc, x + 1, y + 1.1, x + 34);
return y + heightOf(line);
}
case 'subhead': {
doc.setFont('helvetica', 'bold').setFontSize(6.8);
doc.text(line.text, x + 1 + line.depth * 2, y);
return y + heightOf(line);
}
case 'detail': {
doc.setFont('helvetica', 'normal').setFontSize(6.8);
const indent = x + 2 + line.depth * 2;
line.lines.forEach((text, i) => doc.text(text, indent, y + i * LINE_H));
// The amount sits on the first line of a wrapped name, as the printed report has it.
doc.text(line.amount, x + BALANCE_R, y, { align: 'right' });
return y + heightOf(line);
}
case 'subsubtotal': {
doc.setFont('helvetica', 'bold').setFontSize(6.8);
dottedLine(doc, x + BALANCE_R - 22, y - 2.2, x + BALANCE_R);
doc.text(line.amount, x + BALANCE_R, y, { align: 'right' });
return y + heightOf(line);
}
case 'subtotal': {
doc.setFont('helvetica', 'bold').setFontSize(7);
doc.text(line.amount, x + TOTAL_R, y, { align: 'right' });
return y + heightOf(line);
}
case 'standalone': {
doc.setFont('helvetica', 'bold').setFontSize(7);
line.lines.forEach((text, i) => doc.text(text, x + 1, y + i * LINE_H));
doc.text(line.amount, x + BALANCE_R, y, { align: 'right' });
doc.text(line.amount, x + TOTAL_R, y, { align: 'right' });
return y + heightOf(line);
}
case 'grand': {
const top = y + 1.5;
doc.setLineWidth(0.3);
doc.line(x + 1, top, x + COL_W - 2, top);
doc.setFont('helvetica', 'bold').setFontSize(7.5);
doc.text(line.amount, x + TOTAL_R, top + 3.6, { align: 'right' });
doc.setLineWidth(0.2);
doc.line(x + 1, top + 5, x + COL_W - 2, top + 5);
doc.line(x + 1, top + 5.7, x + COL_W - 2, top + 5.7);
return y + heightOf(line);
}
default:
return y + heightOf(line);
}
}
/** Splits one side's lines into per-page chunks that fit between BODY_TOP and BODY_BOTTOM. */
function paginate(lines: Line[]): Line[][] {
const pages: Line[][] = [];
let current: Line[] = [];
let y = BODY_TOP;
for (const line of lines) {
const h = heightOf(line);
if (y + h > BODY_BOTTOM) {
pages.push(current);
current = [];
y = BODY_TOP;
}
current.push(line);
y += h;
}
// Keep the trailing chunk only if it has something on it, but always return at least one page so
// a side with no balances still gets its heading and a zero total.
if (current.length > 0 || pages.length === 0) {
pages.push(current);
}
return pages;
}
export function buildBalanceSheetPdf(input: BalanceSheetPdfInput): jsPDF {
const doc = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4', compress: true });
const creditPages = paginate(flatten(doc, input.creditSide));
const debitPages = paginate(flatten(doc, input.debitSide));
// Both sides print side by side, so the sheet runs as long as the longer of the two, plus a
// final page for the grand totals when they would not fit under the last block.
const pageCount = Math.max(creditPages.length, debitPages.length);
for (let p = 0; p < pageCount; p++) {
if (p > 0) {
doc.addPage();
}
drawPageFrame(doc, input, p + 1, pageCount);
const sides = [
creditPages[p] ?? [],
debitPages[p] ?? []
];
for (let c = 0; c < 2; c++) {
const x = COL_X[c];
let y = BODY_TOP;
for (const line of sides[c]) {
y = drawLine(doc, line, x, y);
}
}
// Sign-off row, on the closing page only.
if (p === pageCount - 1) {
doc
.setFont('helvetica', 'normal')
.setFontSize(6.5)
.setTextColor(...MUTED);
const roles = [
'CLERK',
'ACCOUNTANT / OFFICER',
'ASST. GEN MANAGER',
'GENERAL MANAGER',
'CHAIRMAN'
];
const step = (PAGE_W - MARGIN * 2) / roles.length;
roles.forEach((role, i) => doc.text(role, MARGIN + step * i + step / 2, BODY_BOTTOM + 2, { align: 'center' }));
doc.setTextColor(...INK);
}
}
return doc;
}
@@ -0,0 +1,182 @@
<!--
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="bs-page">
<!-- Controls — not part of the printed sheet. -->
<div class="bs-controls">
<label class="bs-field">
<span>As on date</span>
<input type="date" [(ngModel)]="asOnDate" (change)="load()" />
</label>
<label class="bs-field">
<span>Branch</span>
<select [(ngModel)]="officeId" (ngModelChange)="load()">
<option [ngValue]="null">All branches</option>
@for (office of offices; track office.id) {
<option [ngValue]="office.id">{{ office.name }}</option>
}
</select>
</label>
<label class="bs-check">
<input type="checkbox" [(ngModel)]="hideZeroBalances" (ngModelChange)="applyZeroFilter()" />
<span>Hide nil balances</span>
</label>
<span class="bs-controls__spacer"></span>
@if (!loading && debitSide && creditSide) {
<span class="bs-tally" [class.bs-tally--off]="!tallied">
{{ tallied ? 'Balanced' : 'Out of balance by ' + fmt(difference) }}
</span>
}
<button type="button" class="bs-btn" (click)="load()" [disabled]="loading">
{{ loading ? 'Loading…' : 'Refresh' }}
</button>
<button type="button" class="bs-btn bs-btn--primary" (click)="downloadPdf()" [disabled]="loading || !debitSide">
Download PDF
</button>
</div>
@if (error) {
<div class="bs-alert">{{ error }}</div>
}
@if (loading) {
<div class="bs-loading">Loading balance sheet…</div>
}
@if (!loading && debitSide && creditSide) {
<!-- The sheet itself — this block is what prints. -->
<div class="bs-sheet">
<header class="bs-sheet__head">
<h2>CONSOLIDATED BALANCE SHEET (SUB GROUP WISE)</h2>
<p>AS ON : {{ displayDate(asOnDateLoaded) }}</p>
<p class="bs-sheet__branches">FOR BRANCHES : {{ officeLabel }}</p>
</header>
<div class="bs-sheet__body">
<!-- Liabilities (credit) on the left, assets (debit) on the right — as the printed sheet reads. -->
<section class="bs-col">
<div class="bs-col__title">LIABILITIES ( CREDIT )</div>
<div class="bs-row bs-row--colhead">
<span>ACCOUNT NAME</span>
<span>BALANCE</span>
<span>TOTAL</span>
</div>
@for (group of creditSide.groups; track group.classification) {
@for (root of group.nodes; track root.id) {
<ng-container *ngTemplateOutlet="block; context: { $implicit: root }"></ng-container>
}
}
@if (creditSide.groups.length === 0) {
<div class="bs-empty">No liability, equity or income balances as on this date.</div>
}
<div class="bs-row bs-row--grand">
<span></span>
<span></span>
<span>{{ fmt(creditSide.total) }}</span>
</div>
</section>
<section class="bs-col">
<div class="bs-col__title">ASSETS ( DEBIT )</div>
<div class="bs-row bs-row--colhead">
<span>ACCOUNT NAME</span>
<span>BALANCE</span>
<span>TOTAL</span>
</div>
@for (group of debitSide.groups; track group.classification) {
@for (root of group.nodes; track root.id) {
<ng-container *ngTemplateOutlet="block; context: { $implicit: root }"></ng-container>
}
}
@if (debitSide.groups.length === 0) {
<div class="bs-empty">No asset or expense balances as on this date.</div>
}
<div class="bs-row bs-row--grand">
<span></span>
<span></span>
<span>{{ fmt(debitSide.total) }}</span>
</div>
</section>
</div>
<footer class="bs-sheet__foot">
<span>CLERK</span>
<span>ACCOUNTANT / OFFICER</span>
<span>ASST. GEN MANAGER</span>
<span>GENERAL MANAGER</span>
<span>CHAIRMAN</span>
</footer>
</div>
}
</div>
<!--
One top-level block: the Header account's name, a rule, its lines, then its subtotal in the TOTAL
column.
-->
<ng-template #block let-root>
@if (root.usage === 'DETAIL') {
<!-- A Detail account with no Header of its own: a group of one, so it gets a single line
carrying both its balance and its total rather than a heading repeating its own name. -->
<div class="bs-block bs-block--single">
<div class="bs-row bs-row--standalone">
<span class="bs-name" [title]="root.glCode">{{ root.name }}</span>
<span class="bs-amt">{{ fmt(root.balance) }}</span>
<span class="bs-amt">{{ fmt(root.balance) }}</span>
</div>
</div>
} @else {
<div class="bs-block">
<div class="bs-block__name" [title]="root.glCode">{{ root.name }}</div>
<div class="bs-block__rule"></div>
@for (child of root.children; track child.id) {
<ng-container *ngTemplateOutlet="lines; context: { $implicit: child, depth: 0 }"></ng-container>
}
@if (root.children.length === 0) {
<div class="bs-row bs-row--line">
<span class="bs-name bs-name--muted">No accounts under this group</span>
<span></span>
<span></span>
</div>
}
<div class="bs-row bs-row--subtotal">
<span></span>
<span></span>
<span>{{ fmt(root.balance) }}</span>
</div>
</div>
}
</ng-template>
<!--
Everything below a top-level block. A nested Header keeps its own name and its own subtotal, so a
Header inside a Header still reads as its own sub-group rather than flattening into the parent.
-->
<ng-template #lines let-node let-depth="depth">
@if (node.usage === 'DETAIL') {
<div class="bs-row bs-row--line">
<span class="bs-name" [style.paddingLeft.px]="depth * 14" [title]="node.glCode">{{ node.name }}</span>
<span class="bs-amt">{{ fmt(node.balance) }}</span>
<span></span>
</div>
} @else {
<div class="bs-row bs-row--subhead">
<span class="bs-name" [style.paddingLeft.px]="depth * 14" [title]="node.glCode">{{ node.name }}</span>
<span></span>
<span></span>
</div>
@for (child of node.children; track child.id) {
<ng-container *ngTemplateOutlet="lines; context: { $implicit: child, depth: depth + 1 }"></ng-container>
}
<div class="bs-row bs-row--subsubtotal">
<span></span>
<span class="bs-amt">{{ fmt(node.balance) }}</span>
<span></span>
</div>
}
</ng-template>
@@ -0,0 +1,372 @@
/**
* 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/.
*/
$ink: #111827;
$ink-soft: #374151;
$ink-faint: #9ca3af;
$rule: #cbd5e1;
$border: #e2e8f0;
$accent: #1d4ed8;
$ok: #15803d;
$ok-soft: #f0fdf4;
$danger: #b91c1c;
$danger-soft: #fef2f2;
// Amount columns share one width so BALANCE and TOTAL line up down the whole sheet.
$amt-col: 128px;
.bs-page {
padding: 20px 24px 40px;
background: #f1f5f9;
min-height: 100%;
}
// ── Controls (screen only) ────────────────────────────────────────────────
.bs-controls {
display: flex;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 18px;
&__spacer {
flex: 1 1 auto;
}
}
.bs-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: $ink-soft;
input,
select {
height: 36px;
padding: 0 10px;
border: 1px solid $border;
border-radius: 6px;
background: #fff;
font-size: 13px;
font-weight: 500;
text-transform: none;
letter-spacing: normal;
color: $ink;
}
}
.bs-check {
display: flex;
align-items: center;
gap: 6px;
height: 36px;
font-size: 12.5px;
font-weight: 600;
color: $ink-soft;
cursor: pointer;
user-select: none;
input {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: $accent;
}
}
.bs-btn {
height: 36px;
padding: 0 16px;
border: 1px solid $border;
border-radius: 6px;
background: #fff;
color: $ink-soft;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
&--primary {
background: $ink;
border-color: $ink;
color: #fff;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.bs-tally {
padding: 6px 12px;
border-radius: 999px;
background: $ok-soft;
color: $ok;
font-size: 12px;
font-weight: 700;
&--off {
background: $danger-soft;
color: $danger;
}
}
.bs-alert {
padding: 10px 14px;
border-radius: 6px;
background: $danger-soft;
color: $danger;
border: 1px solid #fecaca;
font-size: 13px;
margin-bottom: 14px;
}
.bs-loading {
padding: 48px 0;
text-align: center;
color: $ink-faint;
font-size: 13.5px;
}
// ── The printed sheet ─────────────────────────────────────────────────────
.bs-sheet {
background: #fff;
border: 1px solid $border;
padding: 22px 26px 18px;
// The ledger reads as a printed report, so the whole sheet is set in a mono face with
// figures on tabular numerals — columns stay aligned however long the names run.
font-family: 'DejaVu Sans Mono', 'Liberation Mono', Consolas, 'Courier New', monospace;
font-variant-numeric: tabular-nums;
color: $ink;
&__head {
text-align: center;
border-bottom: 1px dotted $rule;
padding-bottom: 10px;
margin-bottom: 12px;
h2 {
margin: 0;
font-size: 13.5px;
font-weight: 700;
letter-spacing: 0.04em;
}
p {
margin: 4px 0 0;
font-size: 11.5px;
color: $ink-soft;
}
}
&__branches {
letter-spacing: 0.05em;
}
&__body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
&__foot {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 26px;
padding-top: 10px;
border-top: 1px dotted $rule;
font-size: 10px;
letter-spacing: 0.05em;
color: $ink-soft;
}
}
.bs-col {
padding: 0 16px;
min-width: 0;
&:first-child {
border-right: 1px dotted $rule;
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
&__title {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
padding-bottom: 6px;
}
}
// ── Rows: one grid so name / BALANCE / TOTAL align across every block ──────
.bs-row {
display: grid;
grid-template-columns: minmax(0, 1fr) $amt-col $amt-col;
gap: 8px;
align-items: baseline;
font-size: 11.5px;
line-height: 1.55;
> span:nth-child(2),
> span:nth-child(3) {
text-align: right;
white-space: nowrap;
}
&--colhead {
font-weight: 700;
font-size: 10.5px;
letter-spacing: 0.05em;
border-bottom: 1px dotted $rule;
padding-bottom: 4px;
margin-bottom: 6px;
color: $ink-soft;
}
&--line {
color: $ink-soft;
}
// A nested Header inside a block: its own named sub-group.
&--subhead {
font-weight: 700;
color: $ink;
padding-top: 3px;
}
// Sub-group subtotal sits in the BALANCE column, so it never collides with the
// block's own figure in TOTAL.
&--subsubtotal {
border-top: 1px dotted $rule;
font-weight: 700;
color: $accent;
}
// Top-level block subtotal, in the TOTAL column.
&--subtotal {
font-weight: 700;
color: $ink;
padding-top: 2px;
}
// A Header-less Detail account: its own one-line group.
&--standalone {
font-weight: 700;
color: $ink;
.bs-name {
text-transform: uppercase;
letter-spacing: 0.03em;
}
}
&--grand {
margin-top: 14px;
padding-top: 8px;
border-top: 1px solid $ink;
border-bottom: 3px double $ink;
font-weight: 700;
font-size: 12.5px;
}
}
.bs-name {
overflow-wrap: anywhere;
&--muted {
color: $ink-faint;
font-style: italic;
}
}
.bs-amt {
font-variant-numeric: tabular-nums;
}
.bs-block {
margin-bottom: 14px;
&--single {
margin-bottom: 10px;
}
&__name {
font-size: 11.5px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
// The short dashed rule the printed sheet puts under every group heading.
&__rule {
width: 130px;
border-top: 1px dotted $ink-faint;
margin: 2px 0 4px;
}
}
.bs-empty {
padding: 14px 0;
font-size: 11.5px;
color: $ink-faint;
}
// ── Narrow screens: stack the two sides instead of squeezing the columns ───
@media (width <= 1100px) {
.bs-sheet__body {
grid-template-columns: 1fr;
}
.bs-col {
padding: 0;
&:first-child {
border-right: none;
border-bottom: 1px dotted $rule;
padding-bottom: 16px;
margin-bottom: 16px;
}
}
}
// ── Print: only the sheet, at report proportions ───────────────────────────
@media print {
.bs-controls,
.bs-alert,
.bs-loading {
display: none !important;
}
.bs-page {
padding: 0;
background: #fff;
}
.bs-sheet {
border: none;
padding: 0;
font-size: 10px;
}
.bs-sheet__body {
grid-template-columns: 1fr 1fr;
}
.bs-block {
break-inside: avoid;
}
}
@@ -0,0 +1,218 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AccountingService } from '../accounting.service';
import { OrganizationService } from '../../organization/organization.service';
import { buildBalanceSheetPdf } from './balance-sheet-pdf';
interface BalanceSheetNode {
id: number;
name: string;
glCode: string;
usage: 'HEADER' | 'DETAIL';
balance: number;
children: BalanceSheetNode[];
}
interface BalanceSheetGroup {
classification: string;
total: number;
nodes: BalanceSheetNode[];
}
interface BalanceSheetSide {
label: string;
total: number;
groups: BalanceSheetGroup[];
}
/**
* Consolidated Balance Sheet (sub-group wise), laid out the way the bank's own printed balance
* sheet reads: liabilities on the left, assets on the right, each top-level Header account a named
* block whose Detail accounts are listed under it and subtotalled in the TOTAL column.
*
* Balances come from BalanceSheetApiResource, which reads the live ledger - a Detail account's own
* postings, and a Header's as the recursive sum of its children (a Header never carries a posting
* of its own). A Detail account that sits under no Header at all is shown as its own one-line block
* rather than being dropped.
*/
@Component({
selector: 'mifosx-balance-sheet',
standalone: true,
imports: [
CommonModule,
FormsModule
],
templateUrl: './balance-sheet.component.html',
styleUrl: './balance-sheet.component.scss'
})
export class BalanceSheetComponent implements OnInit {
private accountingService = inject(AccountingService);
private organizationService = inject(OrganizationService);
/** Printed at the head of the PDF. Falls back to a neutral title if the profile is not set up. */
bankName = 'Bank';
asOnDate: string = new Date().toISOString().slice(0, 10);
officeId: number | null = null;
offices: { id: number; name: string }[] = [];
loading = false;
error = '';
asOnDateLoaded = '';
/** What the server returned, kept whole so the zero-balance toggle never needs a refetch. */
private rawDebitSide: BalanceSheetSide | null = null;
private rawCreditSide: BalanceSheetSide | null = null;
debitSide: BalanceSheetSide | null = null;
creditSide: BalanceSheetSide | null = null;
/**
* A working chart of accounts carries far more accounts than a bank actually operates, and a
* printed balance sheet lists only the ones carrying a balance — leaving every nil line in makes
* the sheet unreadable. On by default for that reason; switch it off to audit the full chart.
*/
hideZeroBalances = true;
/** Both sides are derived from the same double-entry ledger, so they can only disagree if a
* posting is missing a leg — worth surfacing rather than hiding. */
get tallied(): boolean {
if (!this.debitSide || !this.creditSide) {
return true;
}
return Math.abs(this.debitSide.total - this.creditSide.total) < 0.01;
}
get difference(): number {
if (!this.debitSide || !this.creditSide) {
return 0;
}
return Math.abs(this.debitSide.total - this.creditSide.total);
}
get officeLabel(): string {
if (this.officeId == null) {
return 'ALL BRANCHES';
}
return (this.offices.find((o) => o.id === this.officeId)?.name ?? '').toUpperCase();
}
ngOnInit(): void {
this.accountingService.getOffices().subscribe({
next: (offices) => (this.offices = offices || []),
error: () => (this.offices = [])
});
this.organizationService.getInstitutionProfile().subscribe({
next: (profile: any) => {
this.bankName = profile?.statementHeaderName || profile?.bankName || this.bankName;
},
// A missing institution profile must not stop the sheet from loading — the PDF just carries
// the fallback title.
error: () => undefined
});
this.load();
}
load(): void {
this.loading = true;
this.error = '';
this.accountingService.getBalanceSheet(this.asOnDate, this.officeId ?? undefined).subscribe({
next: (res: any) => {
this.rawDebitSide = res.debitSide;
this.rawCreditSide = res.creditSide;
this.asOnDateLoaded = res.asOnDate;
this.applyZeroFilter();
this.loading = false;
},
error: () => {
this.error = 'Could not load the balance sheet for this date.';
this.loading = false;
}
});
}
/**
* Rebuilds the displayed sides from what the server sent, dropping nil lines when asked. The
* totals are deliberately left as the server computed them — hiding a nil line must never change
* a figure, and a group total is unaffected by removing children that summed to nothing.
*/
applyZeroFilter(): void {
this.debitSide = this.filterSide(this.rawDebitSide);
this.creditSide = this.filterSide(this.rawCreditSide);
}
private filterSide(side: BalanceSheetSide | null): BalanceSheetSide | null {
if (!side) {
return null;
}
if (!this.hideZeroBalances) {
return side;
}
const groups = side.groups
.map((group) => ({
...group,
nodes: group.nodes
.map((node) => this.pruneZeros(node))
.filter((node): node is BalanceSheetNode => node !== null)
}))
.filter((group) => group.nodes.length > 0);
return { ...side, groups };
}
/** Drops a node whose balance is nil, and any Header left with nothing under it. */
private pruneZeros(node: BalanceSheetNode): BalanceSheetNode | null {
if (Math.abs(node.balance) < 0.005) {
return null;
}
const children = node.children
.map((child) => this.pruneZeros(child))
.filter((child): child is BalanceSheetNode => child !== null);
return { ...node, children };
}
/** Indian grouping with two decimals, matching the printed report (2,76,92,608.00). */
fmt(value: number): string {
return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value ?? 0);
}
/** dd/MM/yyyy, as the printed report dates it. */
displayDate(iso: string): string {
if (!iso) {
return '';
}
const [
y,
m,
d
] = iso.split('-');
return `${d}/${m}/${y}`;
}
/**
* Builds the report as a real PDF rather than printing the page: window.print() takes the whole
* app shell with it and breaks pages wherever they happen to fall, which is what made the earlier
* output unusable.
*/
downloadPdf(): void {
if (!this.debitSide || !this.creditSide) {
return;
}
const doc = buildBalanceSheetPdf({
bankName: this.bankName,
officeLabel: this.officeLabel,
asOnDate: this.asOnDateLoaded || this.asOnDate,
creditSide: this.creditSide,
debitSide: this.debitSide
});
doc.save(`balance-sheet-${this.asOnDateLoaded || this.asOnDate}.pdf`);
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* 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 { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
/**
* Accounting features switched off for this installation.
*
* Nothing is deleted — the components, routes and services all stay in place, and flipping a flag
* back to `false` brings the feature back with its tile and its route. The matching API block lives
* in DisabledFeatureApiFilter on the backend, so a hidden feature cannot be reached by calling the
* endpoint directly either.
*/
export const DISABLED_ACCOUNTING_FEATURES = {
createJournalEntries: true,
frequentPostings: true,
closingEntries: true,
accruals: true,
provisioningEntries: true,
accountingRules: true
};
/**
* Blocks a route belonging to a disabled feature, for anyone who reaches it by typing the URL
* rather than through a tile. `data.feature` names the flag to check.
*/
export const accountingFeatureEnabledGuard: CanActivateFn = (route) => {
const router = inject(Router);
const feature = route.data?.['feature'] as keyof typeof DISABLED_ACCOUNTING_FEATURES | undefined;
if (!feature || !DISABLED_ACCOUNTING_FEATURES[feature]) {
return true;
}
router.navigate(['/accounting'], { replaceUrl: true });
return false;
};
@@ -49,6 +49,27 @@
} }
@if (glStatus && !glError) { @if (glStatus && !glError) {
@if (todayAlreadyOpen) {
<!-- This screen is a one-time "day 1" tool - today's business day already exists, so counting
a drawer here would only be rejected at submit (BranchDayRegisterApiResource rejects
reopening an already-open day). Day Register is where every day after the first belongs. -->
<div class="mcd-gl mcd-gl--error">
<mat-icon>lock</mat-icon>
<div>
<div class="mcd-gl__label">Business Day Already Open</div>
<div class="mcd-gl__value">
{{ todayOpenInfo.businessDate }} — opened by {{ todayOpenInfo.openedByName }}, opening cash ₹{{
todayOpenInfo.openingCash | number: '1.2-2'
}}
</div>
<div class="mcd-gl__hint">
This branch has already opened today's business day — counting the drawer again here would only be rejected
on submit. Deposits, withdrawals and closing today's day happen from Day Register instead.
</div>
<button type="button" mat-stroked-button (click)="goToBranchDay()">Go to Day Register</button>
</div>
</div>
} @else {
<!-- Step 2: what the ledger already says, so the count is counted against something concrete. --> <!-- Step 2: what the ledger already says, so the count is counted against something concrete. -->
<div class="mcd-gl" [class.mcd-gl--fresh]="!glHasBalance"> <div class="mcd-gl" [class.mcd-gl--fresh]="!glHasBalance">
<mat-icon>account_balance</mat-icon> <mat-icon>account_balance</mat-icon>
@@ -88,6 +109,7 @@
type="number" type="number"
min="0" min="0"
class="mcd-grid__input" class="mcd-grid__input"
[style.width.ch]="countInputWidthCh(d)"
[ngModel]="countFor(d)" [ngModel]="countFor(d)"
(ngModelChange)="onCountChange(d, $event)" (ngModelChange)="onCountChange(d, $event)"
/> />
@@ -122,7 +144,9 @@
<mat-icon>error</mat-icon> <mat-icon>error</mat-icon>
<span <span
>Off by ₹{{ >Off by ₹{{
(countedTotal - glTarget < 0 ? glTarget - countedTotal : countedTotal - glTarget).toLocaleString('en-IN') (countedTotal - glTarget < 0 ? glTarget - countedTotal : countedTotal - glTarget).toLocaleString(
'en-IN'
)
}}</span }}</span
> >
} }
@@ -164,4 +188,5 @@
</div> </div>
} }
} }
}
</div> </div>
@@ -115,6 +115,10 @@
color: #64748b; color: #64748b;
line-height: 1.4; line-height: 1.4;
} }
button {
margin-top: 10px;
}
} }
.mcd-grid { .mcd-grid {
@@ -171,7 +175,11 @@
} }
&__input { &__input {
width: 48px; /* Width is set inline per row (see countInputWidthCh()), growing with the digit count typed —
min-width here only covers the fallback before Angular has bound anything. */
min-width: 48px;
max-width: 96px;
transition: width 0.1s ease-out;
text-align: center; text-align: center;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
@@ -66,6 +66,14 @@ export class MigrateCashDenominationComponent implements OnInit {
glStatus: any = null; glStatus: any = null;
glError = ''; glError = '';
/** Set once per office change: whether TODAY's business day is already open for this office. This
* screen is a one-time "day 1" tool (see the class doc) - once a day has actually been opened,
* reopening the same one is exactly what BranchDayRegisterApiResource#migrateCashDenomination
* itself refuses (its own openDayInternal call rejects a day that is already open), so this is
* checked here too, before the user counts an entire drawer only to have the submit rejected. */
todayAlreadyOpen = false;
todayOpenInfo: any = null;
notes: Denom[] = []; notes: Denom[] = [];
coins: Denom[] = []; coins: Denom[] = [];
counts: Record<number, number> = {}; counts: Record<number, number> = {};
@@ -118,6 +126,8 @@ export class MigrateCashDenominationComponent implements OnInit {
this.result = null; this.result = null;
this.successMsg = ''; this.successMsg = '';
this.errorMsg = ''; this.errorMsg = '';
this.todayAlreadyOpen = false;
this.todayOpenInfo = null;
if (!this.officeId) return; if (!this.officeId) return;
this.glLoading = true; this.glLoading = true;
@@ -131,6 +141,15 @@ export class MigrateCashDenominationComponent implements OnInit {
this.glError = res.message || 'No cash GL account is configured for this tenant yet.'; this.glError = res.message || 'No cash GL account is configured for this tenant yet.';
} }
}); });
this.service
.branchDayRegister(this.officeId)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
const openDay = res?.openDay;
this.todayAlreadyOpen = !!openDay && openDay.businessDate === res?.today;
this.todayOpenInfo = this.todayAlreadyOpen ? openDay : null;
});
} }
countFor(d: Denom): number { countFor(d: Denom): number {
@@ -149,6 +168,12 @@ export class MigrateCashDenominationComponent implements OnInit {
this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta); this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta);
} }
/** Widens the count box as the typed count grows more digits, so a large count (hundreds,
* thousands) is never clipped or hard to read inside a field sized for one or two digits. */
countInputWidthCh(d: Denom): number {
return Math.max(3, String(this.countFor(d)).length + 2);
}
get countedTotal(): number { get countedTotal(): number {
return this.allDenoms.reduce((s, d) => s + this.subtotalFor(d), 0); return this.allDenoms.reduce((s, d) => s + this.subtotalFor(d), 0);
} }
@@ -162,7 +162,18 @@
<div class="mob-grid mob-grid--data" formArrayName="glAccountEntries"> <div class="mob-grid mob-grid--data" formArrayName="glAccountEntries">
<div class="mob-grid__row" [formGroupName]="i"> <div class="mob-grid__row" [formGroupName]="i">
<div class="mob-grid__code">{{ openingBalancesData.glAccounts[i].glAccountCode }}</div> <div class="mob-grid__code">{{ openingBalancesData.glAccounts[i].glAccountCode }}</div>
<div class="mob-grid__name">{{ openingBalancesData.glAccounts[i].glAccountName }}</div> <div class="mob-grid__name">
{{ openingBalancesData.glAccounts[i].glAccountName }}
@if (isDenominationManaged(openingBalancesData.glAccounts[i].glAccountId)) {
<span
class="mob-grid__lock"
matTooltip="Set by Migrate Cash Denomination — use that screen to change it"
>
<mat-icon inline="true">lock</mat-icon>
<a [routerLink]="['/accounting/migrate-cash-denomination']">Migrate Cash Denomination</a>
</span>
}
</div>
<div class="mob-grid__amt"> <div class="mob-grid__amt">
<mat-form-field class="mob-grid__input"> <mat-form-field class="mob-grid__input">
<input matInput type="number" min="0" formControlName="debit" placeholder="0.00" /> <input matInput type="number" min="0" formControlName="debit" placeholder="0.00" />
@@ -219,6 +219,33 @@
word-break: break-word; word-break: break-word;
} }
/* Cash at Vault / Cash at Teller — locked, see MigrateOpeningBalancesComponent.isDenominationManaged. */
&__lock {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
padding: 1px 8px 1px 6px;
border-radius: 999px;
background: #fff7ed;
color: #9a3412;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
vertical-align: middle;
mat-icon {
font-size: 13px;
width: 13px;
height: 13px;
}
a {
color: inherit;
text-decoration: underline;
}
}
&__amt { &__amt {
text-align: right; text-align: right;
} }
@@ -17,6 +17,7 @@ import {
} from '@angular/forms'; } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
/** Custom Services */ /** Custom Services */
import { AccountingService } from '../accounting.service'; import { AccountingService } from '../accounting.service';
@@ -43,7 +44,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
GlAccountDisplayComponent, GlAccountDisplayComponent,
CurrencyPipe, CurrencyPipe,
MatIconModule MatIconModule,
MatTooltipModule
] ]
}) })
export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit { export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
@@ -74,6 +76,13 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
debitsSum = 0; debitsSum = 0;
/** Sum total of credits. */ /** Sum total of credits. */
creditsSum = 0; creditsSum = 0;
/** GL account ids mapped to Financial Activity 101 (Cash at Vault) / 102 (Cash at Teller) — the
* account(s) Migrate Cash Denomination owns. Their opening balance is shown here for visibility
* but locked against editing: redefining it as a bare number here, instead of through a counted
* denomination breakdown, would let the GL figure drift away from what is physically in the till.
* The backend enforces the same rule independently (see defineOpeningBalance's
* validateNoDenominationManagedCashAccounts) — this is a convenience, not the source of truth. */
denominationManagedGlAccountIds: number[] = [];
/* Reference of search form */ /* Reference of search form */
@ViewChild('searchFormRef') searchFormRef: ElementRef<any>; @ViewChild('searchFormRef') searchFormRef: ElementRef<any>;
@@ -95,6 +104,22 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
this.officeData = data.offices; this.officeData = data.offices;
this.currencyData = data.currencies.selectedCurrencyOptions; this.currencyData = data.currencies.selectedCurrencyOptions;
}); });
// Office-independent: which GL account(s) this tenant has mapped to Cash at Vault / Cash at
// Teller, so their row can be locked the moment the office's accounts are drawn.
this.accountingService.getFinancialActivityAccounts().subscribe((mappings: any[]) => {
this.denominationManagedGlAccountIds = (mappings || [])
.filter((mapping) => mapping.financialActivityData?.id === 101 || mapping.financialActivityData?.id === 102)
.map((mapping) => mapping.glAccountData?.id)
.filter((id) => id != null);
// Retrieve may already have run before this (fast) request came back — lock any row it
// missed rather than leaving it editable for the rest of this visit.
this.glAccountEntries?.controls.forEach((control) => {
const glAccountId = control.get('glAccountId')?.value;
if (this.isDenominationManaged(glAccountId) && control.enabled) {
control.disable();
}
});
});
} }
/** /**
@@ -136,14 +161,31 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
* @returns {FormGroup} GL Account entry form. * @returns {FormGroup} GL Account entry form.
*/ */
createGLAccountEntryForm(glAccount: any): UntypedFormGroup { createGLAccountEntryForm(glAccount: any): UntypedFormGroup {
return this.formBuilder.group( // A never-touched account arrives as a placeholder row (amount/entryType both null); one that
// already carries an opening balance arrives with its real, committed figure — show that
// instead of leaving the field looking blank.
const existingDebit = glAccount.entryType?.value === 'DEBIT' ? glAccount.amount : null;
const existingCredit = glAccount.entryType?.value === 'CREDIT' ? glAccount.amount : null;
const group = this.formBuilder.group(
{ {
glAccountId: [glAccount.glAccountId], glAccountId: [glAccount.glAccountId],
debit: [null], debit: [existingDebit],
credit: [null] credit: [existingCredit]
}, },
{ validator: onlyOneOfTheFieldsIsRequiredValidator } { validator: onlyOneOfTheFieldsIsRequiredValidator }
); );
if (this.isDenominationManaged(glAccount.glAccountId)) {
// Disabling excludes this row from the form's own .value, so it is never resubmitted here —
// the account keeps whatever Migrate Cash Denomination last set it to.
group.disable();
}
return group;
}
/** Whether this GL account is Cash at Vault / Cash at Teller — locked here, see
* denominationManagedGlAccountIds. */
isDenominationManaged(glAccountId: number): boolean {
return this.denominationManagedGlAccountIds.includes(glAccountId);
} }
/** /**
@@ -176,14 +218,27 @@ export class MigrateOpeningBalancesComponent implements OnInit, AfterViewInit {
this.openingBalancesData = openingBalancesData; this.openingBalancesData = openingBalancesData;
entry.valueChanges.subscribe(() => { entry.valueChanges.subscribe(() => this.recomputeSums());
// Runs once immediately too: valueChanges only fires on a later edit, and the locked cash
// row's own prefilled figure (set once, above, never through a further value change) would
// otherwise never be reflected in the banner at all.
this.recomputeSums();
});
}
/** Total Debit / Total Credit shown in the balance banner. A locked (denomination-managed) row is
* excluded here exactly as it is excluded from the form's own .value used at submit time — this
* screen only needs to balance the accounts it is itself about to (re)define, not the one
* Migrate Cash Denomination already settled and this form can never touch. */
private recomputeSums(): void {
this.debitsSum = 0; this.debitsSum = 0;
this.creditsSum = 0; this.creditsSum = 0;
entry.controls.forEach((value) => { this.glAccountEntries.controls.forEach((control) => {
this.debitsSum += value.value.debit; if (control.disabled) {
this.creditsSum += value.value.credit; return;
}); }
}); this.debitsSum += control.value.debit || 0;
this.creditsSum += control.value.credit || 0;
}); });
} }
@@ -53,10 +53,13 @@
<fa-icon icon="sync" [class.fa-spin]="ledgerLoading"></fa-icon> <fa-icon icon="sync" [class.fa-spin]="ledgerLoading"></fa-icon>
Refresh Refresh
</button> </button>
<!-- New Entry — hidden while Create Journal Entries is disabled, see disabled-features.ts -->
@if (!disabledFeatures.createJournalEntries) {
<button class="gl-hero-btn gl-hero-btn--primary" [routerLink]="['/accounting/journal-entries/create']"> <button class="gl-hero-btn gl-hero-btn--primary" [routerLink]="['/accounting/journal-entries/create']">
<fa-icon icon="plus"></fa-icon> <fa-icon icon="plus"></fa-icon>
New Entry New Entry
</button> </button>
}
</div> </div>
</div> </div>
</div> </div>
@@ -14,6 +14,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
import { merge } from 'rxjs'; import { merge } from 'rxjs';
import { tap, startWith, map, distinctUntilChanged, debounceTime } from 'rxjs/operators'; import { tap, startWith, map, distinctUntilChanged, debounceTime } from 'rxjs/operators';
import { AccountingService } from '../accounting.service'; import { AccountingService } from '../accounting.service';
import { DISABLED_ACCOUNTING_FEATURES } from '../disabled-features';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { JournalEntriesDataSource } from './journal-entry.datasource'; import { JournalEntriesDataSource } from './journal-entry.datasource';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
@@ -90,6 +91,8 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
// ── Misc ────────────────────────────────────────────────────────────────── // ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1); minDate = new Date(2000, 0, 1);
maxDate = new Date(); maxDate = new Date();
/** Accounting features switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
// ── Filter controls ─────────────────────────────────────────────────────── // ── Filter controls ───────────────────────────────────────────────────────
officeName = new UntypedFormControl(); officeName = new UntypedFormControl();
@@ -49,6 +49,18 @@
</div> </div>
} }
@if (firstOpenNeedsCashMigration && !missedOnly) {
<div class="bd-locked">
<mat-icon>lock</mat-icon>
<span>
This branch has never opened a business day, and its Cash at Vault GL account has no opening balance yet. Count
the vault and open the first business day together from
<a [routerLink]="['/accounting/migrate-cash-denomination']">Migrate Cash Denomination</a> — Open Day here is
locked until that is done, so today's cash tracking never starts out of step with the ledger.
</span>
</div>
}
<!-- Missed closes first: these are the ones that need action. --> <!-- Missed closes first: these are the ones that need action. -->
@if (missed.length > 0) { @if (missed.length > 0) {
<div class="db-exceptions"> <div class="db-exceptions">
@@ -703,10 +715,10 @@
<span class="db-muted">· {{ timeOnly(d.closedAt) }}</span> <span class="db-muted">· {{ timeOnly(d.closedAt) }}</span>
} }
</td> </td>
<td class="num">{{ d.closingCash != null ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}</td> <td class="num">{{ hasValue(d.closingCash) ? '₹' + (d.closingCash | number: '1.2-2') : '—' }}</td>
<td class="num">{{ d.bookCash != null ? '₹' + (d.bookCash | number: '1.2-2') : '—' }}</td> <td class="num">{{ hasValue(d.bookCash) ? '₹' + (d.bookCash | number: '1.2-2') : '—' }}</td>
<td> <td>
@if (d.cashDifference == null) { @if (!hasValue(d.cashDifference)) {
— —
} @else if (d.cashDifference === 0) { } @else if (d.cashDifference === 0) {
<span class="db-badge db-badge--ok">Tallied</span> <span class="db-badge db-badge--ok">Tallied</span>
@@ -30,6 +30,36 @@
} }
} }
/* Blocks Open Day until the branch's first-ever open has gone through Migrate Cash Denomination —
see BranchDayComponent.checkFirstOpenCashMigration(). Same shape as .ve-note, in the error palette
the rest of this screen already uses for a missed close (.bd-missed). */
.bd-locked {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #b91c1c;
font-size: 12px;
line-height: 1.5;
mat-icon {
flex: none;
font-size: 18px;
width: 18px;
height: 18px;
}
a {
color: inherit;
font-weight: 600;
text-decoration: underline;
}
}
.bd-success { .bd-success {
padding: 10px 14px; padding: 10px 14px;
margin-bottom: 14px; margin-bottom: 14px;
@@ -10,7 +10,7 @@ import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, map, of, switchMap } from 'rxjs'; import { catchError, forkJoin, map, of, switchMap } from 'rxjs';
import { CashierService } from '../cashier.service'; import { CashierService } from '../cashier.service';
@@ -61,7 +61,8 @@ interface BranchDay {
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
MatIconModule MatIconModule,
RouterLink
] ]
}) })
export class BranchDayComponent implements OnInit { export class BranchDayComponent implements OnInit {
@@ -85,6 +86,12 @@ export class BranchDayComponent implements OnInit {
loaded = false; loaded = false;
errorMsg = ''; errorMsg = '';
successMsg = ''; successMsg = '';
/** True only for a branch that has never opened a business day and whose Cash at Vault / Cash at
* Teller GL account has never had anything posted to it — Open Day here would let day-to-day cash
* tracking begin while the ledger silently stays wherever it already was. The backend enforces
* this independently too (see BranchDayRegisterApiResource#requireCashMigrationBeforeFirstEverOpen);
* this flag only steers the user to the right screen before they hit that rejection. */
firstOpenNeedsCashMigration = false;
// Open-day form // Open-day form
showOpenForm = false; showOpenForm = false;
@@ -202,6 +209,7 @@ export class BranchDayComponent implements OnInit {
this.today = res?.today ?? ''; this.today = res?.today ?? '';
this.canOperate = !!res?.canOperate; this.canOperate = !!res?.canOperate;
this.openDate = this.today; this.openDate = this.today;
this.checkFirstOpenCashMigration();
}); });
this.service this.service
@@ -212,9 +220,42 @@ export class BranchDayComponent implements OnInit {
}); });
} }
/**
* Only relevant the moment this branch has never opened a business day before — every later
* open/close on this screen is untouched, so there is nothing to re-check once history exists.
*/
private checkFirstOpenCashMigration(): void {
if (this.days.length > 0 || this.openDay) {
this.firstOpenNeedsCashMigration = false;
return;
}
this.service.cashGlStatus().subscribe({
next: (status: any) => {
// configured === false means this tenant has no Cash at Vault / Cash at Teller account
// mapped at all — nothing for this check to reconcile against, so it does not block.
this.firstOpenNeedsCashMigration = status?.configured !== false && !status?.hasBalance;
},
error: () => {
this.firstOpenNeedsCashMigration = false;
}
});
}
/** True when the branch has no open day at all, so today can be opened. */ /** True when the branch has no open day at all, so today can be opened. */
get canOpenToday(): boolean { get canOpenToday(): boolean {
return this.canOperate && !this.openDay; return this.canOperate && !this.openDay && !this.firstOpenNeedsCashMigration;
}
/**
* A still-open day's closing/book figures are dropped from the API response entirely (the
* backend's Gson instance omits a null map value rather than sending it as JSON null), so the
* field arrives as undefined, not null. Checking only `!== null` against that — which a stricter
* pass of this file made the mistake of doing — leaves undefined on the "has a value" branch,
* which then renders `'₹' + null` (the number pipe's own null output, string-concatenated) as the
* literal text "₹null". This checks for either, the way an open day actually shows up.
*/
hasValue(value: number | null | undefined): boolean {
return value !== null && value !== undefined;
} }
startOpen(): void { startOpen(): void {
@@ -3189,7 +3189,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Step 1: search on the correct endpoint for this account type // Step 1: search on the correct endpoint for this account type
this.http this.http
.get<any>( .get<any>(
`${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}` `${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}${cfg.extraListQueryParams ? `&${cfg.extraListQueryParams}` : ''}`
) )
.pipe(catchError(() => of(null))) .pipe(catchError(() => of(null)))
.subscribe((res: any) => { .subscribe((res: any) => {
@@ -3278,6 +3278,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
matchField: string; matchField: string;
detailParams: string; detailParams: string;
supportsPagination: boolean; supportsPagination: boolean;
/** Extra fixed query params (e.g. "usage=1") appended to every list-search request for this
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
* for a transaction; a Header can never receive a posting, so it should never be offered. */
extraListQueryParams?: string;
} { } {
const t = acTyp?.toUpperCase(); const t = acTyp?.toUpperCase();
if (t === 'LA') { if (t === 'LA') {
@@ -3331,7 +3335,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
listQueryParam: 'searchParam', listQueryParam: 'searchParam',
matchField: 'glCode', matchField: 'glCode',
detailParams: '?fetchRunningBalance=true', detailParams: '?fetchRunningBalance=true',
supportsPagination: false supportsPagination: false,
// Detail-only: a Header account is a pure grouping node and can never receive a
// transaction, so it must never be a selectable match here in the first place.
extraListQueryParams: 'usage=1'
}; };
} }
if (t === 'CA' || t === 'CB') { if (t === 'CA' || t === 'CB') {
@@ -164,6 +164,13 @@ export class ContactOtpBlockComponent implements OnChanges, OnDestroy {
request$.subscribe({ request$.subscribe({
next: (res: any) => { next: (res: any) => {
this.sending = false; this.sending = false;
// The backend reports failure to actually dispatch the OTP (gateway error, flagged
// number, SMTP failure...) as a 200 response with success:false and a real reason in
// `message` — never a thrown error. Surface that reason instead of treating it as sent.
if (res?.success === false) {
this.errorMessage = res?.message || 'Could not send OTP.';
return;
}
this.otpId = res?.otpId ?? res?.resourceId ?? null; this.otpId = res?.otpId ?? res?.resourceId ?? null;
this.otpSent = true; this.otpSent = true;
this.clearAllDigits(); this.clearAllDigits();
@@ -299,6 +299,9 @@
</mat-icon> </mat-icon>
<a matLine>{{ 'labels.menus.Notifications' | translate }}</a> <a matLine>{{ 'labels.menus.Notifications' | translate }}</a>
</mat-list-item> </mat-list-item>
<!-- Frequent Postings / Create Journal Entry — hidden while disabled, see
accounting/disabled-features.ts -->
@if (!disabledFeatures.frequentPostings) {
<mat-list-item <mat-list-item
[routerLink]="['/accounting/journal-entries/frequent-postings']" [routerLink]="['/accounting/journal-entries/frequent-postings']"
[matTooltipPosition]="tooltipPosition" [matTooltipPosition]="tooltipPosition"
@@ -312,6 +315,8 @@
</mat-icon> </mat-icon>
<a matLine #frequentPostings>{{ 'labels.menus.Frequent Postings' | translate }}</a> <a matLine #frequentPostings>{{ 'labels.menus.Frequent Postings' | translate }}</a>
</mat-list-item> </mat-list-item>
}
@if (!disabledFeatures.createJournalEntries) {
<mat-list-item <mat-list-item
[routerLink]="['/accounting/journal-entries/create']" [routerLink]="['/accounting/journal-entries/create']"
[matTooltipPosition]="tooltipPosition" [matTooltipPosition]="tooltipPosition"
@@ -325,6 +330,7 @@
</mat-icon> </mat-icon>
<a matLine #createJournalEntry>{{ 'labels.menus.Create Journal Entry' | translate }}</a> <a matLine #createJournalEntry>{{ 'labels.menus.Create Journal Entry' | translate }}</a>
</mat-list-item> </mat-list-item>
}
<mat-list-item <mat-list-item
[routerLink]="['/accounting/chart-of-accounts']" [routerLink]="['/accounting/chart-of-accounts']"
[matTooltipPosition]="tooltipPosition" [matTooltipPosition]="tooltipPosition"
@@ -33,6 +33,7 @@ import { MatIcon } from '@angular/material/icon';
import { MatLine } from '@angular/material/grid-list'; import { MatLine } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { remittanceConfig } from '../../../remittances/remittance.config'; import { remittanceConfig } from '../../../remittances/remittance.config';
import { DISABLED_ACCOUNTING_FEATURES } from '../../../accounting/disabled-features';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { catchError, finalize, of, take } from 'rxjs'; import { catchError, finalize, of, take } from 'rxjs';
@@ -81,6 +82,8 @@ export class SidenavComponent implements OnInit, AfterViewInit {
frequentActivities: any[] = frequentActivities; frequentActivities: any[] = frequentActivities;
/** Whether remittance feature is enabled */ /** Whether remittance feature is enabled */
mifosRemittanceEnabled = remittanceConfig.isRemittanceEnabled; mifosRemittanceEnabled = remittanceConfig.isRemittanceEnabled;
/** Accounting features switched off for this installation — see accounting/disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
/** Whether Superset Analytics is enabled */ /** Whether Superset Analytics is enabled */
supersetEnabled = environment.supersetEnabled; supersetEnabled = environment.supersetEnabled;
@@ -275,9 +275,12 @@
<a [routerLink]="['/bank-calendar']" class="ip-reflink" <a [routerLink]="['/bank-calendar']" class="ip-reflink"
><i class="fas fa-calendar-check"></i> Holiday Calendar</a ><i class="fas fa-calendar-check"></i> Holiday Calendar</a
> >
<!-- Closing Entries — hidden while disabled, see accounting/disabled-features.ts -->
@if (!disabledFeatures.closingEntries) {
<a [routerLink]="['/accounting/closing-entries']" class="ip-reflink" <a [routerLink]="['/accounting/closing-entries']" class="ip-reflink"
><i class="fas fa-calendar-day"></i> Financial Year / Closing Entries</a ><i class="fas fa-calendar-day"></i> Financial Year / Closing Entries</a
> >
}
<a [routerLink]="['/branch-ops/day-register']" class="ip-reflink" <a [routerLink]="['/branch-ops/day-register']" class="ip-reflink"
><i class="fas fa-clock"></i> Business Date (Day Register)</a ><i class="fas fa-clock"></i> Business Date (Day Register)</a
> >
@@ -12,6 +12,7 @@ import { catchError, of } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from '../organization.service'; import { OrganizationService } from '../organization.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { DISABLED_ACCOUNTING_FEATURES } from '../../accounting/disabled-features';
const BANK_TYPES = [ const BANK_TYPES = [
{ value: 'COOPERATIVE', label: 'Co-operative Bank' }, { value: 'COOPERATIVE', label: 'Co-operative Bank' },
@@ -63,6 +64,8 @@ export class InstitutionProfileComponent implements OnInit {
private organizationService = inject(OrganizationService); private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
/** Accounting features switched off for this installation — see accounting/disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
bankTypes = BANK_TYPES; bankTypes = BANK_TYPES;
ownershipTypes = OWNERSHIP_TYPES; ownershipTypes = OWNERSHIP_TYPES;
licenseStatuses = LICENSE_STATUSES; licenseStatuses = LICENSE_STATUSES;
@@ -104,7 +104,8 @@ export class PlatformMenuAccessComponent {
'X-Api-Key': this.apiKey, 'X-Api-Key': this.apiKey,
// Any real tenant satisfies the platform's tenant-resolution filter; the target tenant for // Any real tenant satisfies the platform's tenant-resolution filter; the target tenant for
// read/write is this header's own value, so it is set to whichever tenant is actually in play. // read/write is this header's own value, so it is set to whichever tenant is actually in play.
'Fineract-Platform-TenantId': tenantForFilter || this.selectedTenant || (this.tenants[0]?.identifier ?? 'default') 'Fineract-Platform-TenantId':
tenantForFilter || this.selectedTenant || this.tenants[0]?.identifier || environment.fineractPlatformTenantId
}; };
} }
@@ -126,7 +127,9 @@ export class PlatformMenuAccessComponent {
this.rememberKey(); this.rememberKey();
this.loadingTenants = true; this.loadingTenants = true;
try { try {
const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), { headers: this.headers('default') }); const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), {
headers: this.headers(environment.fineractPlatformTenantId)
});
if (!res.ok) { if (!res.ok) {
throw new Error( throw new Error(
res.status === 403 || res.status === 401 ? 'Invalid or inactive API key.' : `Server error (${res.status}).` res.status === 403 || res.status === 401 ? 'Invalid or inactive API key.' : `Server error (${res.status}).`
@@ -110,8 +110,7 @@
[shareProductsTemplate]="shareProductsTemplate" [shareProductsTemplate]="shareProductsTemplate"
[accountingRuleData]="accountingRuleData" [accountingRuleData]="accountingRuleData"
[shareProduct]="shareProduct" [shareProduct]="shareProduct"
[taskPermission]="'CREATE_SHAREPRODUCT'" [showActions]="false"
(submitEvent)="submit()"
></mifosx-share-product-preview-step> ></mifosx-share-product-preview-step>
</div> </div>
</mat-tab> </mat-tab>
@@ -250,6 +250,7 @@
</div> </div>
</div> </div>
@if (showActions) {
<div class="shpp-actions"> <div class="shpp-actions">
<button mat-raised-button [routerLink]="['../']"> <button mat-raised-button [routerLink]="['../']">
{{ 'labels.buttons.Cancel' | translate }} {{ 'labels.buttons.Cancel' | translate }}
@@ -258,3 +259,4 @@
{{ 'labels.buttons.Submit' | translate }} {{ 'labels.buttons.Submit' | translate }}
</button> </button>
</div> </div>
}
@@ -52,6 +52,10 @@ export class ShareProductPreviewStepComponent {
@Input() accountingRuleData: any; @Input() accountingRuleData: any;
@Input() shareProduct: any; @Input() shareProduct: any;
@Input() taskPermission: string; @Input() taskPermission: string;
/** Set to false when the host page already renders its own Cancel/Submit bar for this step
* (the Create flow's shared bottom action bar) — Edit's stepper has no such bar of its own, so
* this defaults to true and keeps rendering the button there. */
@Input() showActions = true;
@Output() submitEvent = new EventEmitter(); @Output() submitEvent = new EventEmitter();
marketPriceDisplayedColumns: string[] = [ marketPriceDisplayedColumns: string[] = [
@@ -91,16 +91,10 @@
[savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []" [savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []"
[submitting]="submitting" [submitting]="submitting"
(submitEvent)="submit()" (submitEvent)="submit()"
(previousEvent)="prev()"
> >
</mifosx-savings-account-preview-step> </mifosx-savings-account-preview-step>
} }
<div class="csa-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
</div>
</div> </div>
</mat-tab> </mat-tab>
</mat-tab-group> </mat-tab-group>
@@ -585,13 +585,17 @@
</div> </div>
<!-- <!--
Submit & Cancel — Previous lives in the parent wizard's tab action bar Previous, Cancel & Submit all in one row. Previous emits back to the parent wizard rather than
now that this component renders inside a mat-tab-group rather than a using matStepperPrevious, since this component renders inside a mat-tab-group rather than a
mat-stepper. Re-introducing matStepperPrevious here would require a mat-stepper and has no CdkStepper ancestor to bind to.
CdkStepper ancestor and would crash the view on init.
--> -->
@if (!hideButtons) { @if (!hideButtons) {
<div class="preview-submit-row"> <div class="preview-submit-row">
<button mat-stroked-button (click)="previousEvent.emit()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<div class="preview-submit-row__right">
<button mat-stroked-button [routerLink]="['../..']"> <button mat-stroked-button [routerLink]="['../..']">
{{ 'labels.buttons.Cancel' | translate }} {{ 'labels.buttons.Cancel' | translate }}
</button> </button>
@@ -606,4 +610,5 @@
{{ 'labels.buttons.Submit' | translate }} {{ 'labels.buttons.Submit' | translate }}
</button> </button>
</div> </div>
</div>
} }
@@ -13,17 +13,21 @@
padding: 0.5rem 0; padding: 0.5rem 0;
} }
/* Submit + Cancel row at the bottom of the preview. Previous lives in the /* Previous, Cancel & Submit row at the bottom of the preview. */
parent wizard's tab action bar. */
.preview-submit-row { .preview-submit-row {
display: flex; display: flex;
justify-content: flex-end; justify-content: space-between;
align-items: center; align-items: center;
gap: 12px;
margin-top: 18px; margin-top: 18px;
padding-top: 16px; padding-top: 16px;
border-top: 1px solid #f1f5f9; border-top: 1px solid #f1f5f9;
&__right {
display: flex;
align-items: center;
gap: 12px;
}
button { button {
height: 42px !important; height: 42px !important;
border-radius: 10px !important; border-radius: 10px !important;
@@ -116,6 +116,9 @@ export class SavingsAccountPreviewStepComponent implements OnChanges {
/** Form submission event */ /** Form submission event */
@Output() submitEvent = new EventEmitter(); @Output() submitEvent = new EventEmitter();
/** Goes back a tab in the parent wizard — kept here so Previous renders in the same row as
* Cancel/Submit instead of a separate action bar underneath. */
@Output() previousEvent = new EventEmitter();
ngOnChanges(): void { ngOnChanges(): void {
if (this.activeClientMembers?.length > 0) { if (this.activeClientMembers?.length > 0) {
@@ -149,6 +149,13 @@ export class SharesAccountViewComponent implements OnInit {
// capital that was never issued. // capital that was never issued.
if (!this.isSettled) { if (!this.isSettled) {
this.buttonConfig.removeOption('Share Certificate'); this.buttonConfig.removeOption('Share Certificate');
} else {
// "Apply Collection" settles the account's ORIGINAL application funding -- a one-time
// step. Once collectionApplied is true there is nothing left to settle this way; any
// further share purchases on this account go through "Apply Additional Shares" instead,
// which raises and settles its own tranche. Leaving this button up after settlement invited
// reopening the dialog against an account with no unapplied receipt to find.
this.buttonConfig.removeButton('Apply Collection');
} }
} }
} }
@@ -206,7 +206,9 @@ export class GlSettingsDialogComponent implements OnInit {
loadGlAccountsAndMapping(): void { loadGlAccountsAndMapping(): void {
this.loading = true; this.loading = true;
this.http.get<any[]>('/glaccounts?manualEntriesAllowed=true&disabled=false').subscribe({ // usage=1 (Detail only): these settings map real Sponsor DD transactions onto a GL account, and
// a Header account can never receive one, so it must never be offered here.
this.http.get<any[]>('/glaccounts?manualEntriesAllowed=true&disabled=false&usage=1').subscribe({
next: (accounts) => { next: (accounts) => {
this.glAccounts = accounts || []; this.glAccounts = accounts || [];
this.sponsorDDService.getGlMappings().subscribe({ this.sponsorDDService.getGlMappings().subscribe({
+2 -2
View File
@@ -20,8 +20,8 @@
// Tenants the login screen offers, and which one it starts on. Every identifier here has to exist // Tenants the login screen offers, and which one it starts on. Every identifier here has to exist
// in the tenants table of the tenant-store database, or the platform rejects the request as an // in the tenants table of the tenant-store database, or the platform rejects the request as an
// unknown tenant. // unknown tenant.
window["env"]["fineractPlatformTenantId"] = 'default'; window["env"]["fineractPlatformTenantId"] = 'lincpay';
window["env"]["fineractPlatformTenantIds"] = 'default,abc_bank,socity_bank'; window["env"]["fineractPlatformTenantIds"] = 'lincpay';
window['env']['tenantLogoUrl'] = ''; window['env']['tenantLogoUrl'] = '';
window['env']['tenantLogoUrlDark'] = ''; window['env']['tenantLogoUrlDark'] = '';
+2 -2
View File
@@ -19,8 +19,8 @@ export const environment = {
version: env.mifos_x.version, version: env.mifos_x.version,
hash: env.mifos_x.hash, hash: env.mifos_x.hash,
// For connecting to server running elsewhere update the tenant identifier // For connecting to server running elsewhere update the tenant identifier
fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'default', fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'lincpay',
fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'default', fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'lincpay',
// For connecting to others servers running elsewhere update the base API URL // For connecting to others servers running elsewhere update the base API URL
baseApiUrls: baseApiUrls:
loadedEnv['fineractApiUrls'] || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin, loadedEnv['fineractApiUrls'] || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin,
+2 -2
View File
@@ -22,8 +22,8 @@ export const environment = {
version: env.mifos_x.version, version: env.mifos_x.version,
hash: env.mifos_x.hash, hash: env.mifos_x.hash,
// For connecting to server running elsewhere update the tenant identifier // For connecting to server running elsewhere update the tenant identifier
fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'default', fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'lincpay',
fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'default', fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'lincpay',
// For connecting to others servers running elsewhere update the base API URL // For connecting to others servers running elsewhere update the base API URL
baseApiUrls: baseApiUrls:
loadedEnv.fineractApiUrls || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin, loadedEnv.fineractApiUrls || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin,