update saving product code

This commit is contained in:
Nitesh Yadav
2026-04-13 14:02:54 +05:30
parent 79e455c4ed
commit 4679c6f729
37 changed files with 4103 additions and 1889 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(npx *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(select-object *)",
"Bash(git config *)",
"Bash(set *)"
]
},
"$version": 3
}
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npx *)"
]
}
}
+2 -2
View File
@@ -87,9 +87,9 @@ export class Accounting {
} else if (accountingRuleId === 2) {
return { code: 'CASH', id: accountingRuleId, value: 'Cash' };
} else if (accountingRuleId === 3) {
return { code: 'ACCRUAL_PERIODIC', id: accountingRuleId, value: 'Accural (periodic)' };
return { code: 'ACCRUAL_PERIODIC', id: accountingRuleId, value: 'Accrual (periodic)' };
} else if (accountingRuleId === 4) {
return { code: 'ACCRUAL_UPFRONT', id: accountingRuleId, value: 'Accural (upfront)' };
return { code: 'ACCRUAL_UPFRONT', id: accountingRuleId, value: 'Accrual (upfront)' };
}
return { code: 'INVALID', id: accountingRuleId, value: 'Invalid' };
}
@@ -12,14 +12,21 @@
<h2 class="sp-header__title">{{ 'labels.heading.Create Savings Product' | translate }}</h2>
</div>
<mat-tab-group class="sp-tabs" animationDuration="200ms" [preserveContent]="true" [(selectedIndex)]="selectedTabIndex">
<mat-tab-group
class="sp-tabs"
animationDuration="200ms"
[preserveContent]="true"
[(selectedIndex)]="selectedTabIndex"
>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-info-circle sp-tab-icon"></i>
{{ 'labels.inputs.DETAILS' | translate }}
</ng-template>
<div class="sp-tab-content">
<mifosx-saving-product-details-step></mifosx-saving-product-details-step>
<mifosx-saving-product-details-step
[savingProductsTemplate]="savingProductsTemplate"
></mifosx-saving-product-details-step>
</div>
</mat-tab>
@@ -72,6 +79,18 @@
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-mobile-alt sp-tab-icon"></i>
Digital
</ng-template>
<div class="sp-tab-content">
<mifosx-saving-product-limits-step
[savingProductsTemplate]="savingProductsTemplate"
></mifosx-saving-product-limits-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-book sp-tab-icon"></i>
@@ -114,19 +133,19 @@
<div class="sp-action-bar__right">
@if (selectedTabIndex > 0) {
<button mat-raised-button (click)="selectedTabIndex = selectedTabIndex - 1">
<i class="fas fa-arrow-left" style="margin-right: 8px;"></i>
<i class="fas fa-arrow-left" style="margin-right: 8px"></i>
{{ 'labels.buttons.Previous' | translate }}
</button>
}
@if (selectedTabIndex < totalTabs - 1) {
<button mat-raised-button color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
{{ 'labels.buttons.Next' | translate }}
<i class="fas fa-arrow-right" style="margin-left: 8px;"></i>
<i class="fas fa-arrow-right" style="margin-left: 8px"></i>
</button>
}
@if (savingProductFormValid && selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" (click)="submit()">
<i class="fas fa-check" style="margin-right: 8px;"></i>
<i class="fas fa-check" style="margin-right: 8px"></i>
{{ 'labels.buttons.Submit' | translate }}
</button>
}
@@ -35,11 +35,11 @@ $primary: #1353a0;
.sp-tabs {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
::ng-deep {
.mat-mdc-tab-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
border-bottom: 1px solid rgb(0 0 0 / 8%);
}
.mat-mdc-tab {
@@ -77,10 +77,10 @@ $primary: #1353a0;
align-items: center;
margin-top: 20px;
padding: 16px 0;
border-top: 1px solid rgba(0, 0, 0, 0.08);
border-top: 1px solid rgb(0 0 0 / 8%);
&__cancel {
color: rgba(0, 0, 0, 0.6);
color: rgb(0 0 0 / 60%);
}
&__right {
@@ -1,4 +1,4 @@
/**
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
@@ -16,6 +16,7 @@ import { SavingProductCurrencyStepComponent } from '../saving-product-stepper/sa
import { SavingProductTermsStepComponent } from '../saving-product-stepper/saving-product-terms-step/saving-product-terms-step.component';
import { SavingProductSettingsStepComponent } from '../saving-product-stepper/saving-product-settings-step/saving-product-settings-step.component';
import { SavingProductChargesStepComponent } from '../saving-product-stepper/saving-product-charges-step/saving-product-charges-step.component';
import { SavingProductLimitsStepComponent } from '../saving-product-stepper/saving-product-limits-step/saving-product-limits-step.component';
import { SavingProductAccountingStepComponent } from '../saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component';
/** Custom Services */
@@ -38,6 +39,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
SavingProductTermsStepComponent,
SavingProductSettingsStepComponent,
SavingProductChargesStepComponent,
SavingProductLimitsStepComponent,
SavingProductAccountingStepComponent,
SavingProductPreviewStepComponent
]
@@ -58,6 +60,8 @@ export class CreateSavingProductComponent {
savingProductSettingsStep: SavingProductSettingsStepComponent;
@ViewChild(SavingProductChargesStepComponent, { static: true })
savingProductChargesStep: SavingProductChargesStepComponent;
@ViewChild(SavingProductLimitsStepComponent, { static: true })
savingProductLimitsStep: SavingProductLimitsStepComponent;
@ViewChild(SavingProductAccountingStepComponent, { static: true })
savingProductAccountingStep: SavingProductAccountingStepComponent;
@@ -100,7 +104,7 @@ export class CreateSavingProductComponent {
}
get totalTabs(): number {
return this.savingProductFormValid ? 7 : 6;
return this.savingProductFormValid ? 8 : 7;
}
get savingProductFormValid() {
@@ -120,18 +124,44 @@ export class CreateSavingProductComponent {
...this.savingProductTermsStep.savingProductTerms,
...this.savingProductSettingsStep.savingProductSettings,
...this.savingProductChargesStep.savingProductCharges,
...this.savingProductLimitsStep.savingProductLimits,
...this.savingProductAccountingStep.savingProductAccounting
};
}
submit() {
// TODO: Update once language and date settings are setup
private prepareSavingProductPayload() {
const savingProduct = {
...this.savingProduct,
charges: this.savingProduct.charges.map((charge: any) => ({ id: charge.id })),
locale: this.settingsService.language.code // locale required for nominalAnnualInterestRate
locale: this.settingsService.language.code,
dateFormat: 'dd MMMM yyyy'
};
delete savingProduct.advancedAccountingRules;
// Convert lockinPeriodFrequencyType from string to integer
// Backend expects: 0=DAYS, 1=WEEKS, 2=MONTHS, 3=YEARS
if (savingProduct.lockinPeriodFrequencyType) {
const frequencyTypeMapping: { [key: string]: number } = {
DAYS: 0,
WEEKS: 1,
MONTHS: 2,
YEARS: 3
};
savingProduct.lockinPeriodFrequencyType = frequencyTypeMapping[savingProduct.lockinPeriodFrequencyType];
}
Object.keys(savingProduct).forEach((key) => {
if (savingProduct[key] === '' || savingProduct[key] === null || savingProduct[key] === undefined) {
delete savingProduct[key];
}
});
return savingProduct;
}
submit() {
const savingProduct = this.prepareSavingProductPayload();
console.log('Saving product submit payload:', savingProduct);
this.productsService.createSavingProduct(savingProduct).subscribe((response: any) => {
this.router.navigate(
[
@@ -12,7 +12,12 @@
<h2 class="sp-header__title">{{ 'labels.heading.Edit Savings Product' | translate }}</h2>
</div>
<mat-tab-group class="sp-tabs" animationDuration="200ms" [preserveContent]="true" [(selectedIndex)]="selectedTabIndex">
<mat-tab-group
class="sp-tabs"
animationDuration="200ms"
[preserveContent]="true"
[(selectedIndex)]="selectedTabIndex"
>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-info-circle sp-tab-icon"></i>
@@ -74,6 +79,18 @@
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-mobile-alt sp-tab-icon"></i>
Digital
</ng-template>
<div class="sp-tab-content">
<mifosx-saving-product-limits-step
[savingProductsTemplate]="savingProductAndTemplate"
></mifosx-saving-product-limits-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-book sp-tab-icon"></i>
@@ -116,19 +133,19 @@
<div class="sp-action-bar__right">
@if (selectedTabIndex > 0) {
<button mat-raised-button (click)="selectedTabIndex = selectedTabIndex - 1">
<i class="fas fa-arrow-left" style="margin-right: 8px;"></i>
<i class="fas fa-arrow-left" style="margin-right: 8px"></i>
{{ 'labels.buttons.Previous' | translate }}
</button>
}
@if (selectedTabIndex < totalTabs - 1) {
<button mat-raised-button color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
{{ 'labels.buttons.Next' | translate }}
<i class="fas fa-arrow-right" style="margin-left: 8px;"></i>
<i class="fas fa-arrow-right" style="margin-left: 8px"></i>
</button>
}
@if (savingProductFormValidAndNotPristine && selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" (click)="submit()">
<i class="fas fa-check" style="margin-right: 8px;"></i>
<i class="fas fa-check" style="margin-right: 8px"></i>
{{ 'labels.buttons.Submit' | translate }}
</button>
}
@@ -35,11 +35,11 @@ $primary: #1353a0;
.sp-tabs {
background: var(--md-sys-color-surface, #fff);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
::ng-deep {
.mat-mdc-tab-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
border-bottom: 1px solid rgb(0 0 0 / 8%);
}
.mat-mdc-tab {
@@ -77,10 +77,10 @@ $primary: #1353a0;
align-items: center;
margin-top: 20px;
padding: 16px 0;
border-top: 1px solid rgba(0, 0, 0, 0.08);
border-top: 1px solid rgb(0 0 0 / 8%);
&__cancel {
color: rgba(0, 0, 0, 0.6);
color: rgb(0 0 0 / 60%);
}
&__right {
@@ -1,4 +1,4 @@
/**
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
@@ -16,6 +16,7 @@ import { SavingProductCurrencyStepComponent } from '../saving-product-stepper/sa
import { SavingProductTermsStepComponent } from '../saving-product-stepper/saving-product-terms-step/saving-product-terms-step.component';
import { SavingProductSettingsStepComponent } from '../saving-product-stepper/saving-product-settings-step/saving-product-settings-step.component';
import { SavingProductChargesStepComponent } from '../saving-product-stepper/saving-product-charges-step/saving-product-charges-step.component';
import { SavingProductLimitsStepComponent } from '../saving-product-stepper/saving-product-limits-step/saving-product-limits-step.component';
import { SavingProductAccountingStepComponent } from '../saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component';
/** Custom Services */
@@ -38,6 +39,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
SavingProductTermsStepComponent,
SavingProductSettingsStepComponent,
SavingProductChargesStepComponent,
SavingProductLimitsStepComponent,
SavingProductAccountingStepComponent,
SavingProductPreviewStepComponent
]
@@ -58,6 +60,8 @@ export class EditSavingProductComponent {
savingProductSettingsStep: SavingProductSettingsStepComponent;
@ViewChild(SavingProductChargesStepComponent, { static: true })
savingProductChargesStep: SavingProductChargesStepComponent;
@ViewChild(SavingProductLimitsStepComponent, { static: true })
savingProductLimitsStep: SavingProductLimitsStepComponent;
@ViewChild(SavingProductAccountingStepComponent, { static: true })
savingProductAccountingStep: SavingProductAccountingStepComponent;
@@ -100,7 +104,7 @@ export class EditSavingProductComponent {
}
get totalTabs(): number {
return this.savingProductFormValidAndNotPristine ? 7 : 6;
return this.savingProductFormValidAndNotPristine ? 8 : 7;
}
get savingProductFormValidAndNotPristine() {
@@ -115,6 +119,7 @@ export class EditSavingProductComponent {
!this.savingProductTermsForm.pristine ||
!this.savingProductSettingsForm.pristine ||
!this.savingProductChargesStep.pristine ||
!this.savingProductLimitsStep.pristine ||
!this.savingProductAccountingForm.pristine)
);
}
@@ -126,18 +131,44 @@ export class EditSavingProductComponent {
...this.savingProductTermsStep.savingProductTerms,
...this.savingProductSettingsStep.savingProductSettings,
...this.savingProductChargesStep.savingProductCharges,
...this.savingProductLimitsStep.savingProductLimits,
...this.savingProductAccountingStep.savingProductAccounting
};
}
submit() {
// TODO: Update once language and date settings are setup
private prepareSavingProductPayload() {
const savingProduct = {
...this.savingProduct,
charges: this.savingProduct.charges.map((charge: any) => ({ id: charge.id })),
locale: this.settingsService.language.code // locale required for nominalAnnualInterestRate
locale: this.settingsService.language.code,
dateFormat: 'dd MMMM yyyy'
};
delete savingProduct.advancedAccountingRules;
// Convert lockinPeriodFrequencyType from string to integer
// Backend expects: 0=DAYS, 1=WEEKS, 2=MONTHS, 3=YEARS
if (savingProduct.lockinPeriodFrequencyType) {
const frequencyTypeMapping: { [key: string]: number } = {
DAYS: 0,
WEEKS: 1,
MONTHS: 2,
YEARS: 3
};
savingProduct.lockinPeriodFrequencyType = frequencyTypeMapping[savingProduct.lockinPeriodFrequencyType];
}
Object.keys(savingProduct).forEach((key) => {
if (savingProduct[key] === '' || savingProduct[key] === null || savingProduct[key] === undefined) {
delete savingProduct[key];
}
});
return savingProduct;
}
submit() {
const savingProduct = this.prepareSavingProductPayload();
console.log('Saving product update payload:', savingProduct);
this.productsService
.updateSavingProduct(this.savingProductAndTemplate.id, savingProduct)
.subscribe((response: any) => {
@@ -1,4 +1,4 @@
<!--
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
@@ -7,13 +7,19 @@
-->
<form [formGroup]="savingProductAccountingForm">
<!-- Accounting Rule -->
<div class="spa-section">
<div class="spa-section__header">
<span class="spa-section__icon">
<i class="fas fa-book"></i>
<h3>{{ 'labels.inputs.Accounting Rule' | translate }}</h3>
</span>
<h3>Accounting</h3>
</div>
<div class="spa-section__body">
<div class="spa-group">
<div class="spa-group__header">
<h4>Accounting Rule</h4>
</div>
<div class="spa-body">
<mat-radio-group class="spa-radio-group" formControlName="accountingRule">
@for (accountingRule of accountingRuleData; track accountingRule; let i = $index) {
<mat-radio-button [value]="i + 1">
@@ -22,341 +28,105 @@
}
</mat-radio-group>
</div>
@if (!isCashOrAccrualAccounting()) {
<div class="spa-note">
Select a cash or accrual accounting rule to configure GL mappings. The default "None" rule does not require
any accounting mappings.
</div>
}
@if (isCashOrAccrualAccounting()) {
<!-- Assets -->
<div class="spa-section">
<div class="spa-section__header">
<i class="fas fa-university"></i>
<h3>{{ 'labels.heading.Assets' | translate }}</h3>
@for (section of accountingSectionConfigs; track section.key) {
<div class="spa-group">
<div class="spa-group__header">
<h4>{{ section.title }}</h4>
<button type="button" class="spa-add-btn" (click)="addOtherGlMapping(section.otherMappingsControlName)">
+ Add Other
</button>
</div>
<div class="spa-list">
@for (row of section.rows; track row.key) {
@if (
(!row.accrualOnly || isAccrualAccounting()) && (!row.dormancyOnly || isDormancyTrackingEnabled())
) {
<div class="spa-row">
<div class="spa-row__meta">
<span
class="spa-pill"
[class.spa-pill--green]="section.pillClass === 'green'"
[class.spa-pill--amber]="section.pillClass === 'amber'"
[class.spa-pill--blue]="section.pillClass === 'blue'"
>
{{ section.pillLabel }}
</span>
<span class="spa-row__label">{{ row.label }}</span>
</div>
<div class="spa-grid">
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.savingsReferenceAccountId"
[glAccountList]="assetAccountData"
[required]="true"
[inputLabel]="'Saving reference'"
></mifosx-gl-account-selector>
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.overdraftPortfolioControlId"
[glAccountList]="assetAccountData"
[required]="true"
[inputLabel]="'Overdraft portfolio'"
></mifosx-gl-account-selector>
@if (isAccrualAccounting()) {
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.feesReceivableAccountId"
[glAccountList]="assetAccountData"
[required]="true"
[inputLabel]="'Fees Receivable'"
></mifosx-gl-account-selector>
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.penaltiesReceivableAccountId"
[glAccountList]="assetAccountData"
[required]="true"
[inputLabel]="'Penalties Receivable'"
class="spa-row__input"
[inputFormControl]="savingProductAccountingForm.get(row.controlName)"
[glAccountList]="row.options"
[required]="row.required"
[inputLabel]="row.inputLabel"
></mifosx-gl-account-selector>
</div>
}
@if (isAccrualAccounting() && allowOverdraft.value) {
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.interestReceivableAccountId"
[glAccountList]="assetAccountData"
[required]="false"
[inputLabel]="'Interest Receivable'"
></mifosx-gl-account-selector>
}
</div>
<div class="spa-list" [formArrayName]="section.otherMappingsControlName">
@for (
otherRow of getOtherGlMappingsArray(section.otherMappingsControlName).controls;
track $index;
let i = $index
) {
<div class="spa-other-row-wrap">
<div class="spa-row spa-row--other" [formGroupName]="i">
<div class="spa-row__meta spa-row__meta--other">
<span
class="spa-pill"
[class.spa-pill--green]="section.pillClass === 'green'"
[class.spa-pill--amber]="section.pillClass === 'amber'"
[class.spa-pill--blue]="section.pillClass === 'blue'"
>
{{ section.pillLabel }}
</span>
<input
class="spa-row__name spa-row__name--other-account"
formControlName="accountName"
placeholder="Enter account name..."
type="text"
/>
</div>
<!-- Liabilities -->
<div class="spa-section">
<div class="spa-section__header">
<i class="fas fa-file-invoice-dollar"></i>
<h3>{{ 'labels.heading.Liabilities' | translate }}</h3>
</div>
<div class="spa-grid">
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.savingsControlAccountId"
[glAccountList]="liabilityAccountData"
[required]="true"
[inputLabel]="'Saving control'"
></mifosx-gl-account-selector>
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.transfersInSuspenseAccountId"
[glAccountList]="liabilityAccountData"
[required]="true"
[inputLabel]="'Savings transfers in suspense'"
></mifosx-gl-account-selector>
@if (isAccrualAccounting()) {
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.interestPayableAccountId"
[glAccountList]="liabilityAccountData"
[required]="true"
[inputLabel]="'Interest Payable'"
></mifosx-gl-account-selector>
}
@if (isDormancyTrackingActive.value) {
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.escheatLiabilityId"
[glAccountList]="liabilityAccountData"
[required]="true"
[inputLabel]="'Escheat liability'"
></mifosx-gl-account-selector>
<div class="spa-row__actions spa-row__actions--other">
<mat-form-field class="spa-row__other-field" appearance="outline" subscriptSizing="dynamic">
<mat-label>GL Code</mat-label>
<mat-select
[formControl]="getOtherGlMappingControl(section.otherMappingsControlName, i, 'glAccountId')"
>
@for (glAccount of section.otherAccountOptions; track glAccount.id) {
<mat-option [value]="glAccount.id">
{{ '(' + glAccount.glCode + ') ' + glAccount.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<!-- Expenses -->
<div class="spa-section">
<div class="spa-section__header">
<i class="fas fa-money-bill-wave"></i>
<h3>{{ 'labels.heading.Expenses' | translate }}</h3>
</div>
<div class="spa-grid">
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.interestOnSavingsAccountId"
[glAccountList]="expenseAccountData"
[required]="true"
[inputLabel]="'Interest on savings'"
></mifosx-gl-account-selector>
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.writeOffAccountId"
[glAccountList]="expenseAccountData"
[required]="true"
[inputLabel]="'Write-off'"
></mifosx-gl-account-selector>
</div>
</div>
<!-- Income -->
<div class="spa-section">
<div class="spa-section__header">
<i class="fas fa-chart-line"></i>
<h3>{{ 'labels.heading.Income' | translate }}</h3>
</div>
<div class="spa-grid">
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.incomeFromFeeAccountId"
[glAccountList]="incomeAccountData"
[required]="true"
[inputLabel]="'Income from fees'"
></mifosx-gl-account-selector>
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.incomeFromPenaltyAccountId"
[glAccountList]="incomeAccountData"
[required]="true"
[inputLabel]="'Income from penalties'"
></mifosx-gl-account-selector>
<mifosx-gl-account-selector
class="spa-field"
[inputFormControl]="savingProductAccountingForm.controls.incomeFromInterestId"
[glAccountList]="incomeAccountData"
[required]="true"
[inputLabel]="'Overdraft Interest Income'"
></mifosx-gl-account-selector>
</div>
</div>
<!-- Advanced Accounting Rules -->
<div class="spa-section">
<div class="spa-section__header">
<i class="fas fa-cogs"></i>
<h3>{{ 'labels.heading.Advanced Accounting Rules' | translate }}</h3>
</div>
<div class="spa-body">
<div class="spa-checkbox-row">
<mat-checkbox formControlName="advancedAccountingRules">
{{ 'labels.heading.Advanced Accounting Rules' | translate }}
</mat-checkbox>
</div>
@if (savingProductAccountingForm.value.advancedAccountingRules) {
<!-- Payment Fund Sources -->
<div class="spa-subsection">
<div class="spa-subsection__header">
<h4>{{ 'labels.heading.Configure Fund Sources for Payment Channels' | translate }}</h4>
<button
type="button"
mat-flat-button
color="primary"
(click)="add('PaymentFundSource', paymentChannelToFundSourceMappings)"
class="spa-add-btn"
class="spa-remove-btn spa-remove-btn--side"
(click)="removeOtherGlMapping(section.otherMappingsControlName, i)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
<i class="fas fa-times"></i>
</button>
</div>
@if (paymentChannelToFundSourceMappings.value.length !== 0) {
<div class="spa-table-wrap">
<table mat-table [dataSource]="paymentChannelToFundSourceMappings.value" class="spa-table">
<ng-container matColumnDef="paymentTypeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payment Type' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource">
{{ paymentFundSource.paymentTypeId | find: paymentTypeData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="fundSourceAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fund Source' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource">
{{ paymentFundSource.fundSourceAccountId | find: combinedAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('PaymentFundSource', paymentChannelToFundSourceMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button mat-icon-button color="warn" (click)="delete(paymentChannelToFundSourceMappings, i)">
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="paymentFundSourceDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: paymentFundSourceDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Fees to Income -->
@if (existCharges()) {
<div class="spa-subsection">
<div class="spa-subsection__header">
<h4>{{ 'labels.heading.Map Fees to Specific Income Accounts' | translate }}</h4>
<button
type="button"
mat-flat-button
color="primary"
(click)="add('FeesIncome', feeToIncomeAccountMappings)"
class="spa-add-btn"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (feeToIncomeAccountMappings.value.length !== 0) {
<div class="spa-table-wrap">
<table mat-table [dataSource]="feeToIncomeAccountMappings.value" class="spa-table">
<ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fees' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome">
{{ feesIncome.chargeId | find: chargeData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome">
{{ feesIncome.incomeAccountId | find: incomeAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('FeesIncome', feeToIncomeAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button mat-icon-button color="warn" (click)="delete(feeToIncomeAccountMappings, i)">
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Penalties to Income -->
<div class="spa-subsection">
<div class="spa-subsection__header">
<h4>{{ 'labels.heading.Map Penalties to Specific Income Accounts' | translate }}</h4>
<button
type="button"
mat-flat-button
color="primary"
(click)="add('PenaltyIncome', penaltyToIncomeAccountMappings)"
class="spa-add-btn"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (penaltyToIncomeAccountMappings.value.length !== 0) {
<div class="spa-table-wrap">
<table mat-table [dataSource]="penaltyToIncomeAccountMappings.value" class="spa-table">
<ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Penalty' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome">
{{ penaltyIncome.chargeId | find: penaltyData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome">
{{ penaltyIncome.incomeAccountId | find: incomeAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('PenaltyIncome', penaltyToIncomeAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button mat-icon-button color="warn" (click)="delete(penaltyToIncomeAccountMappings, i)">
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
</table>
</div>
}
</div>
}
}
</div>
</div>
}
</form>
@@ -6,26 +6,22 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$primary: #1353a0;
$accent: #0ea5e9;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-500: #9aa0a6;
$gray-700: #5f6368;
$gray-500: #6b7280;
$gray-700: #334155;
$gray-900: #202124;
$primary: #1353a0;
.spa-section {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
background: #fff;
}
& + & {
margin-top: 16px;
}
&__header {
.spa-section__header {
display: flex;
align-items: center;
gap: 10px;
@@ -33,97 +29,286 @@ $gray-900: #202124;
background: $gray-50;
border-bottom: 1px solid $gray-200;
i {
font-size: 14px;
color: $accent;
}
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
}
}
.spa-body {
padding: 16px 24px;
}
.spa-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
}
.spa-field {
width: 100%;
&--full {
grid-column: 1 / -1;
}
}
.spa-radio-group {
display: flex;
gap: 24px;
flex-wrap: wrap;
@media (max-width: 600px) {
flex-direction: column;
gap: 12px;
}
}
.spa-checkbox-row {
display: flex;
.spa-section__icon {
display: inline-flex;
align-items: center;
padding: 4px 0;
margin-bottom: 12px;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: #eff6ff;
color: #2563eb;
font-size: 13px;
}
.spa-subsection {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid $gray-200;
.spa-section__body {
padding: 16px 24px 20px;
}
.spa-group {
& + & {
margin-top: 20px;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
gap: 16px;
margin: 0 0 12px;
}
h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: $gray-700;
}
margin: 0 0 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #94a3b8;
}
}
.spa-add-btn {
height: 32px !important;
border-radius: 8px !important;
font-size: 12px !important;
font-weight: 600 !important;
box-shadow: 0 1px 3px rgba($primary, 0.3) !important;
white-space: nowrap;
border: 0;
background: transparent;
color: #2563eb;
font-size: 12px;
font-weight: 700;
padding: 0;
cursor: pointer;
}
.spa-table-wrap {
border: 1px solid $border;
.spa-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.spa-list + .spa-list {
margin-top: 12px;
}
.spa-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
background: #fff;
@media (width <= 900px) {
flex-direction: column;
align-items: stretch;
}
}
.spa-table {
.spa-row:has(mifosx-gl-account-selector) {
align-items: stretch;
}
.spa-row--other {
border-style: dashed;
border-color: #cbd5e1;
background: #f8fafc;
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
align-items: center;
padding: 12px 16px;
@media (width <= 1100px) {
grid-template-columns: 1fr;
}
}
.spa-other-row-wrap {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
@media (width <= 1100px) {
grid-template-columns: 1fr;
gap: 8px;
}
}
.spa-row__meta {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1 1 auto;
}
.spa-row__meta--other {
min-width: 0;
flex: 1 1 auto;
align-self: stretch;
}
.spa-row__label {
font-size: 14px;
font-weight: 600;
color: $gray-700;
}
.spa-row__input {
flex: 0 0 22rem;
max-width: 22rem;
min-width: 18rem;
@media (width <= 900px) {
width: 100%;
max-width: 100%;
min-width: 0;
}
}
.spa-row__input--other {
flex: 1 1 auto;
max-width: 100%;
min-width: 0;
}
.spa-row__input :is(mifosx-gl-account-selector) {
width: 100%;
display: block;
}
.spa-row__input :is(mifosx-gl-account-selector) :is(.layout-column) {
width: 100%;
margin-right: 0 !important;
}
.spa-row__input :is(mifosx-gl-account-selector) :is(mat-form-field) {
width: 100%;
}
.spa-row__actions {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.spa-row__actions--other {
justify-content: flex-end;
align-self: stretch;
width: 100%;
@media (width <= 1100px) {
justify-content: flex-start;
}
}
.spa-row__name {
width: 100%;
min-width: 0;
max-width: 24rem;
height: 40px;
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 0 12px;
font-size: 14px;
background: #fff;
@media (width <= 900px) {
max-width: 100%;
}
}
.spa-row__name--code {
max-width: 20rem;
}
.spa-row__name--other-account {
max-width: 100%;
flex: 1 1 auto;
height: 48px;
}
.spa-row__other-field {
width: 100%;
min-width: 0;
}
.spa-row__other-field :is(mat-form-field) {
width: 100%;
}
.spa-row__other-field :is(.mat-mdc-form-field-required-marker) {
display: none;
}
.spa-remove-btn {
border: 0;
background: transparent;
color: #ef4444;
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.spa-remove-btn--side {
place-self: center end;
margin-right: 4px;
@media (width <= 1100px) {
justify-self: start;
margin-right: 0;
}
}
.spa-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
&--green {
background: #dcfce7;
color: #16a34a;
}
&--amber {
background: #fef3c7;
color: #d97706;
}
&--blue {
background: #dbeafe;
color: #2563eb;
}
}
datalist {
display: none;
}
@media (width <= 900px) {
.spa-row__meta {
margin-bottom: 4px;
}
.spa-row__input {
flex: 1 1 auto;
max-width: 100%;
}
}
@@ -1,4 +1,4 @@
/**
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
@@ -6,489 +6,495 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, Input, inject } from '@angular/core';
import { Component, Input, OnInit, inject } from '@angular/core';
import {
UntypedFormGroup,
UntypedFormBuilder,
FormsModule,
ReactiveFormsModule,
UntypedFormArray,
Validators,
UntypedFormBuilder,
UntypedFormControl,
ReactiveFormsModule
UntypedFormGroup,
Validators
} from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { SelectBase } from 'app/shared/form-dialog/formfield/model/select-base';
import { TranslateService } from '@ngx-translate/core';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { GlAccountSelectorComponent } from '../../../../shared/accounting/gl-account-selector/gl-account-selector.component';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatButton, MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import {
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { FindPipe } from '../../../../pipes/find.pipe';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { GlAccountSelectorComponent } from 'app/shared/accounting/gl-account-selector/gl-account-selector.component';
import { Accounting } from 'app/core/utils/accounting';
import { GLAccount } from 'app/shared/models/general.model';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income';
type AccountingRowConfig = {
key: string;
label: string;
inputLabel: string;
controlName: string;
options: GLAccount[];
required?: boolean;
accrualOnly?: boolean;
dormancyOnly?: boolean;
};
type AccountingSectionConfig = {
key: AccountingSectionKey;
title: string;
pillLabel: string;
pillClass: 'green' | 'amber' | 'blue';
rows: AccountingRowConfig[];
otherMappingsControlName: string;
otherAccountOptions: GLAccount[];
};
type OtherGlMappingValue = {
accountName: string;
glAccountId: number | null;
};
@Component({
selector: 'mifosx-saving-product-accounting-step',
templateUrl: './saving-product-accounting-step.component.html',
styleUrls: ['./saving-product-accounting-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
ReactiveFormsModule,
MatRadioGroup,
MatRadioButton,
GlAccountSelectorComponent,
MatCheckbox,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatIconButton,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
FindPipe
GlAccountSelectorComponent
]
})
export class SavingProductAccountingStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private accounting = inject(Accounting);
@Input() savingProductsTemplate: any;
@Input() accountingRuleData: any;
@Input() isDormancyTrackingActive: UntypedFormControl;
@Input() isDormancyTrackingActive: any;
@Input() savingProductFormValid: boolean;
@Input() allowOverdraft: UntypedFormControl;
@Input() allowOverdraft: any;
savingProductAccountingForm: UntypedFormGroup;
chargeData: any;
penaltyData: any;
paymentTypeData: any;
assetAccountData: any;
incomeAccountData: any;
expenseAccountData: any;
liabilityAccountData: any;
incomeAndLiabilityAccountData: any;
combinedAccountData: any[];
paymentFundSourceDisplayedColumns: string[] = [
'paymentTypeId',
'fundSourceAccountId',
'actions'
];
feesPenaltyIncomeDisplayedColumns: string[] = [
'chargeId',
'incomeAccountId',
'actions'
];
liabilityAccountOptions: GLAccount[] = [];
expenseAccountOptions: GLAccount[] = [];
incomeAccountOptions: GLAccount[] = [];
assetAccountOptions: GLAccount[] = [];
accountingSectionConfigs: AccountingSectionConfig[] = [];
constructor() {
this.createsavingProductAccountingForm();
this.createSavingProductAccountingForm();
this.setConditionalControls();
}
ngOnInit() {
this.chargeData = this.savingProductsTemplate.chargeOptions || [];
this.penaltyData = this.savingProductsTemplate.penaltyOptions || [];
this.paymentTypeData = this.savingProductsTemplate.paymentTypeOptions || [];
this.assetAccountData = this.savingProductsTemplate.accountingMappingOptions.assetAccountOptions || [];
this.incomeAccountData = this.savingProductsTemplate.accountingMappingOptions.incomeAccountOptions || [];
this.expenseAccountData = this.savingProductsTemplate.accountingMappingOptions.expenseAccountOptions || [];
this.liabilityAccountData = this.savingProductsTemplate.accountingMappingOptions.liabilityAccountOptions || [];
this.combinedAccountData = [
...this.assetAccountData,
...this.incomeAccountData,
...this.expenseAccountData,
...this.liabilityAccountData
];
this.combinedAccountData.sort((a, b) => {
const valueA = a.name.toLowerCase();
const valueB = b.name.toLowerCase();
if (valueA < valueB) {
return -1;
}
if (valueA > valueB) {
return 1;
}
return 0;
const mappings = this.savingProductsTemplate?.accountingMappings || {};
const accountingMappingOptions = this.savingProductsTemplate?.accountingMappingOptions || {};
const accountingRuleId = this.savingProductsTemplate?.accountingRule?.id || 1;
this.assetAccountOptions = accountingMappingOptions.assetAccountOptions || [];
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
this.buildAccountingSectionConfigs();
this.savingProductAccountingForm.patchValue({
accountingRule: accountingRuleId
});
this.syncAccountingControls(accountingRuleId);
this.patchAccountingValues(mappings);
this.setOverdraftControl();
this.setDormancyControl();
}
createSavingProductAccountingForm() {
this.savingProductAccountingForm = this.formBuilder.group({
accountingRule: [1],
assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]),
expensesOtherGlMappings: this.formBuilder.array([]),
incomeOtherGlMappings: this.formBuilder.array([])
});
}
private setConditionalControls() {
this.savingProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((accountingRule: number) => {
this.syncAccountingControls(accountingRule);
});
}
private patchAccountingValues(mappings: any) {
this.savingProductAccountingForm.patchValue({
accountingRule: this.savingProductsTemplate.accountingRule.id
savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '',
savingsControlAccountId: mappings.savingsControlAccount?.id || '',
transfersInSuspenseAccountId: mappings.transfersInSuspenseAccount?.id || '',
interestOnSavingsAccountId: mappings.interestOnSavingsAccount?.id || '',
incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '',
incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '',
incomeFromInterestId: mappings.incomeFromInterest?.id || '',
overdraftPortfolioControlId: mappings.overdraftPortfolioControl?.id || '',
writeOffAccountId: mappings.writeOffAccount?.id || '',
feesReceivableAccountId: mappings.feeReceivableAccount?.id || '',
penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '',
interestPayableAccountId: mappings.interestPayableAccount?.id || '',
interestReceivableAccountId: mappings.interestReceivableAccount?.id || '',
escheatLiabilityId: mappings.escheatLiabilityAccount?.id || this.liabilityAccountOptions[0]?.id || ''
});
if (this.isCashOrAccrualAccounting()) {
this.savingProductAccountingForm.patchValue({
savingsReferenceAccountId: this.savingProductsTemplate.accountingMappings.savingsReferenceAccount.id,
overdraftPortfolioControlId: this.savingProductsTemplate.accountingMappings.overdraftPortfolioControl.id,
savingsControlAccountId: this.savingProductsTemplate.accountingMappings.savingsControlAccount.id,
transfersInSuspenseAccountId: this.savingProductsTemplate.accountingMappings.transfersInSuspenseAccount.id,
interestOnSavingsAccountId: this.savingProductsTemplate.accountingMappings.interestOnSavingsAccount.id,
writeOffAccountId: this.savingProductsTemplate.accountingMappings.writeOffAccount.id,
incomeFromFeeAccountId: this.savingProductsTemplate.accountingMappings.incomeFromFeeAccount.id,
incomeFromPenaltyAccountId: this.savingProductsTemplate.accountingMappings.incomeFromPenaltyAccount.id,
incomeFromInterestId: this.savingProductsTemplate.accountingMappings.incomeFromInterest.id,
advancedAccountingRules:
this.savingProductsTemplate.paymentChannelToFundSourceMappings ||
this.savingProductsTemplate.feeToIncomeAccountMappings ||
this.savingProductsTemplate.penaltyToIncomeAccountMappings ||
this.savingProductsTemplate.accrualCharges
? true
: false
});
this.setOtherGlMappings(
'assetsOtherGlMappings',
mappings.otherGlMappings?.assets || [],
this.assetAccountOptions
);
this.setOtherGlMappings(
'liabilitiesOtherGlMappings',
mappings.otherGlMappings?.liabilities || [],
this.liabilityAccountOptions
);
this.setOtherGlMappings(
'expensesOtherGlMappings',
mappings.otherGlMappings?.expenses || [],
this.expenseAccountOptions
);
this.setOtherGlMappings(
'incomeOtherGlMappings',
mappings.otherGlMappings?.income || [],
this.incomeAccountOptions
);
}
}
if (this.isAccrualAccounting()) {
this.savingProductAccountingForm.patchValue({
feesReceivableAccountId: this.savingProductsTemplate.accountingMappings.feeReceivableAccount.id,
penaltiesReceivableAccountId: this.savingProductsTemplate.accountingMappings.penaltyReceivableAccount.id,
interestReceivableAccountId:
this.savingProductsTemplate.accountingMappings.interestReceivableAccount == null || undefined
? ''
: this.savingProductsTemplate.accountingMappings.interestReceivableAccount.id,
interestPayableAccountId: this.savingProductsTemplate.accountingMappings.interestPayableAccount.id
private createOtherGlMappingGroup(otherGlMapping: Partial<OtherGlMappingValue> = {}) {
return this.formBuilder.group({
accountName: [
otherGlMapping.accountName || '',
Validators.required
],
glAccountId: [
otherGlMapping.glAccountId || '',
Validators.required
]
});
}
if (this.isDormancyTrackingActive.value) {
this.savingProductAccountingForm.patchValue({
escheatLiabilityId: this.savingProductsTemplate.accountingMappings.escheatLiabilityAccount.id
});
}
this.savingProductAccountingForm.setControl(
'paymentChannelToFundSourceMappings',
this.formBuilder.array(
(this.savingProductsTemplate.paymentChannelToFundSourceMappings || []).map((paymentFundSource: any) => ({
paymentTypeId: paymentFundSource.paymentType.id,
fundSourceAccountId: paymentFundSource.fundSourceAccount.id
}))
)
);
this.savingProductAccountingForm.setControl(
'feeToIncomeAccountMappings',
this.formBuilder.array(
(this.savingProductsTemplate.feeToIncomeAccountMappings || []).map((feesIncome: any) => ({
chargeId: feesIncome.charge.id,
incomeAccountId: feesIncome.incomeAccount.id
}))
)
);
this.savingProductAccountingForm.setControl(
'penaltyToIncomeAccountMappings',
this.formBuilder.array(
(this.savingProductsTemplate.penaltyToIncomeAccountMappings || []).map((penaltyIncome: any) => ({
chargeId: penaltyIncome.charge.id,
incomeAccountId: penaltyIncome.incomeAccount.id
}))
)
);
}
getOtherGlMappingsArray(controlName: string): UntypedFormArray {
return this.savingProductAccountingForm.get(controlName) as UntypedFormArray;
}
createsavingProductAccountingForm() {
this.savingProductAccountingForm = this.formBuilder.group({
accountingRule: [1]
});
}
setConditionalControls() {
this.savingProductAccountingForm.get('accountingRule').valueChanges.subscribe((accountingRule: any) => {
if (accountingRule === 2 || accountingRule === 3) {
this.savingProductAccountingForm.addControl(
'savingsReferenceAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'overdraftPortfolioControlId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'savingsControlAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'transfersInSuspenseAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'interestOnSavingsAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'writeOffAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'incomeFromFeeAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'incomeFromPenaltyAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'incomeFromInterestId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl('advancedAccountingRules', new UntypedFormControl(false));
if (accountingRule === 3) {
this.savingProductAccountingForm.addControl(
'feesReceivableAccountId',
new UntypedFormControl('', Validators.required)
);
this.savingProductAccountingForm.addControl(
'penaltiesReceivableAccountId',
new UntypedFormControl('', Validators.required)
);
if (this.allowOverdraft.value) {
this.savingProductAccountingForm.addControl('interestReceivableAccountId', new UntypedFormControl(''));
}
this.allowOverdraft.valueChanges.subscribe((allowOverdraft: boolean) => {
if (allowOverdraft) {
this.savingProductAccountingForm.addControl('interestReceivableAccountId', new UntypedFormControl(''));
} else {
this.savingProductAccountingForm.removeControl('interestReceivableAccountId');
}
});
this.savingProductAccountingForm.addControl(
'interestPayableAccountId',
new UntypedFormControl('', Validators.required)
);
}
if (accountingRule === 2) {
this.savingProductAccountingForm.removeControl('feesReceivableAccountId');
this.savingProductAccountingForm.removeControl('penaltiesReceivableAccountId');
this.savingProductAccountingForm.removeControl('interestPayableAccountId');
}
if (this.isDormancyTrackingActive.value) {
this.savingProductAccountingForm.addControl(
'escheatLiabilityId',
new UntypedFormControl('', Validators.required)
);
}
this.isDormancyTrackingActive.valueChanges.subscribe((isDormancyTrackingActive: boolean) => {
if (isDormancyTrackingActive) {
this.savingProductAccountingForm.addControl(
'escheatLiabilityId',
new UntypedFormControl('', Validators.required)
);
} else {
this.savingProductAccountingForm.removeControl('escheatLiabilityId');
}
});
this.savingProductAccountingForm
.get('advancedAccountingRules')
.valueChanges.subscribe((advancedAccountingRules: boolean) => {
if (advancedAccountingRules) {
this.savingProductAccountingForm.addControl(
'paymentChannelToFundSourceMappings',
this.formBuilder.array([])
);
this.savingProductAccountingForm.addControl('feeToIncomeAccountMappings', this.formBuilder.array([]));
this.savingProductAccountingForm.addControl('penaltyToIncomeAccountMappings', this.formBuilder.array([]));
} else {
this.savingProductAccountingForm.removeControl('paymentChannelToFundSourceMappings');
this.savingProductAccountingForm.removeControl('feeToIncomeAccountMappings');
this.savingProductAccountingForm.removeControl('penaltyToIncomeAccountMappings');
}
});
} else {
this.savingProductAccountingForm.removeControl('savingsReferenceAccountId');
this.savingProductAccountingForm.removeControl('overdraftPortfolioControlId');
this.savingProductAccountingForm.removeControl('savingsControlAccountId');
this.savingProductAccountingForm.removeControl('transfersInSuspenseAccountId');
this.savingProductAccountingForm.removeControl('interestOnSavingsAccountId');
this.savingProductAccountingForm.removeControl('writeOffAccountId');
this.savingProductAccountingForm.removeControl('incomeFromFeeAccountId');
this.savingProductAccountingForm.removeControl('incomeFromPenaltyAccountId');
this.savingProductAccountingForm.removeControl('incomeFromInterestId');
this.savingProductAccountingForm.removeControl('advancedAccountingRules');
this.savingProductAccountingForm.removeControl('escheatLiabilityId');
this.savingProductAccountingForm.removeControl('feesReceivableAccountId');
this.savingProductAccountingForm.removeControl('penaltiesReceivableAccountId');
this.savingProductAccountingForm.removeControl('interestReceivableAccountId');
this.savingProductAccountingForm.removeControl('interestPayableAccountId');
}
});
}
get paymentChannelToFundSourceMappings(): UntypedFormArray {
return this.savingProductAccountingForm.get('paymentChannelToFundSourceMappings') as UntypedFormArray;
}
get feeToIncomeAccountMappings(): UntypedFormArray {
return this.savingProductAccountingForm.get('feeToIncomeAccountMappings') as UntypedFormArray;
}
get penaltyToIncomeAccountMappings(): UntypedFormArray {
return this.savingProductAccountingForm.get('penaltyToIncomeAccountMappings') as UntypedFormArray;
}
setSavingProductAccountingFormDirty() {
if (this.savingProductAccountingForm.pristine) {
this.savingProductAccountingForm.markAsDirty();
}
}
existCharges(): boolean {
return this.chargeData.length > 0;
}
add(formType: string, formArray: UntypedFormArray) {
const data = { ...this.getData(formType), pristine: false };
const dialogRef = this.dialog.open(FormDialogComponent, { data, width: '20rem' });
dialogRef.afterClosed().subscribe((response: any) => {
if (response.data) {
formArray.push(response.data);
this.setSavingProductAccountingFormDirty();
}
});
}
edit(formType: string, formArray: UntypedFormArray, index: number) {
const data = { ...this.getData(formType, formArray.at(index).value), layout: { addButtonText: 'Edit' } };
const dialogRef = this.dialog.open(FormDialogComponent, { data });
dialogRef.afterClosed().subscribe((response: any) => {
if (response.data) {
formArray.at(index).patchValue(response.data.value);
this.setSavingProductAccountingFormDirty();
}
});
}
delete(formArray: UntypedFormArray, index: number) {
const dialogRef = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `this` }
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response.delete) {
formArray.removeAt(index);
this.setSavingProductAccountingFormDirty();
}
});
}
getData(formType: string, values?: any) {
switch (formType) {
case 'PaymentFundSource':
return {
title: this.translateService.instant('labels.heading.Configure Fund Sources for Payment Channels'),
formfields: this.getPaymentFundSourceFormfields(values)
};
case 'FeesIncome':
return {
title: this.translateService.instant('labels.heading.Map Fees to Specific Income Accounts'),
formfields: this.getFeesIncomeFormfields(values)
};
case 'PenaltyIncome':
return {
title: this.translateService.instant('labels.heading.Map Penalties to Specific Income Accounts'),
formfields: this.getPenaltyIncomeFormfields(values)
};
}
}
getPaymentFundSourceFormfields(values?: any) {
const formfields: FormfieldBase[] = [
new SelectBase({
controlName: 'paymentTypeId',
label: this.translateService.instant('labels.inputs.Payment Type'),
value: values ? values.paymentTypeId : this.paymentTypeData[0].id,
options: { label: 'name', value: 'id', data: this.paymentTypeData },
required: true,
order: 1
}),
new SelectBase({
controlName: 'fundSourceAccountId',
label: this.translateService.instant('labels.inputs.Fund Source'),
value: values ? values.fundSourceAccountId : this.combinedAccountData[0].id,
options: { label: 'name', value: 'id', data: this.combinedAccountData },
required: true,
order: 2
private setOtherGlMappings(controlName: string, otherGlMappings: any[], options: GLAccount[]) {
const otherGlMappingsArray = this.getOtherGlMappingsArray(controlName);
otherGlMappingsArray.clear();
(otherGlMappings || []).forEach((otherGlMapping) => {
otherGlMappingsArray.push(
this.createOtherGlMappingGroup({
accountName: otherGlMapping.accountName,
glAccountId:
otherGlMapping.glAccountId || options.find((option) => option.glCode === otherGlMapping.glCode)?.id || null
})
];
return formfields;
);
});
}
getFeesIncomeFormfields(values?: any) {
const formfields: FormfieldBase[] = [
new SelectBase({
controlName: 'chargeId',
label: this.translateService.instant('labels.inputs.Fees'),
value: values ? values.chargeId : this.chargeData[0].id,
options: { label: 'name', value: 'id', data: this.chargeData },
required: true,
order: 1
}),
new SelectBase({
controlName: 'incomeAccountId',
label: this.translateService.instant('labels.inputs.Income Account'),
value: values ? values.incomeAccountId : this.incomeAccountData[0].id,
options: { label: 'name', value: 'id', data: this.incomeAccountData },
required: true,
order: 2
})
];
return formfields;
addOtherGlMapping(controlName: string) {
this.getOtherGlMappingsArray(controlName).push(this.createOtherGlMappingGroup());
}
getPenaltyIncomeFormfields(values?: any) {
const formfields: FormfieldBase[] = [
new SelectBase({
controlName: 'chargeId',
label: this.translateService.instant('labels.inputs.Penalty'),
value: values ? values.chargeId : this.penaltyData[0].id,
options: { label: 'name', value: 'id', data: this.penaltyData },
required: true,
order: 1
}),
new SelectBase({
controlName: 'incomeAccountId',
label: this.translateService.instant('labels.inputs.Income Account'),
value: values ? values.incomeAccountId : this.incomeAccountData[0].id,
options: { label: 'name', value: 'id', data: this.incomeAccountData },
required: true,
order: 2
})
];
return formfields;
removeOtherGlMapping(controlName: string, index: number) {
this.getOtherGlMappingsArray(controlName).removeAt(index);
}
getOtherGlMappingControl(controlName: string, index: number, fieldName: string): UntypedFormControl {
return this.getOtherGlMappingsArray(controlName).at(index).get(fieldName) as UntypedFormControl;
}
private clearOtherGlMappings() {
[
'assetsOtherGlMappings',
'liabilitiesOtherGlMappings',
'expensesOtherGlMappings',
'incomeOtherGlMappings'
].forEach((controlName) => this.getOtherGlMappingsArray(controlName).clear());
}
private setOverdraftControl() {
this.allowOverdraft?.valueChanges?.subscribe(() => {
this.syncAccountingControls(this.savingProductAccountingForm.value.accountingRule);
});
}
private setDormancyControl() {
this.isDormancyTrackingActive?.valueChanges?.subscribe(() => {
this.syncAccountingControls(this.savingProductAccountingForm.value.accountingRule);
});
}
private getOtherGlMappings() {
const mapOtherGlMappings = (controlName: string, options: GLAccount[]) =>
this.getOtherGlMappingsArray(controlName)
.value.filter((item: OtherGlMappingValue) => !!item.accountName || !!item.glAccountId)
.map((item: OtherGlMappingValue) => ({
accountName: item.accountName,
glCode: options.find((option) => option.id === item.glAccountId)?.glCode || ''
}))
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
const otherGlMappings = {
assets: mapOtherGlMappings('assetsOtherGlMappings', this.assetAccountOptions),
liabilities: mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
expenses: mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions),
income: mapOtherGlMappings('incomeOtherGlMappings', this.incomeAccountOptions)
};
return Object.values(otherGlMappings).some((value) => value.length) ? otherGlMappings : null;
}
isDormancyTrackingEnabled(): boolean {
return this.isDormancyTrackingActive?.value ?? this.isDormancyTrackingActive ?? false;
}
private syncAccountingControls(accountingRule: number) {
const isDormancyActive = this.isDormancyTrackingEnabled();
const addControl = (controlName: string, required = false) => {
if (!this.savingProductAccountingForm.contains(controlName)) {
this.savingProductAccountingForm.addControl(
controlName,
new UntypedFormControl('', required ? Validators.required : undefined)
);
} else if (required) {
this.savingProductAccountingForm.get(controlName)?.setValidators(Validators.required);
this.savingProductAccountingForm.get(controlName)?.updateValueAndValidity({ emitEvent: false });
}
};
const removeControl = (controlName: string) => {
if (this.savingProductAccountingForm.contains(controlName)) {
this.savingProductAccountingForm.removeControl(controlName);
}
};
if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) {
addControl('savingsReferenceAccountId', true);
addControl('savingsControlAccountId', true);
addControl('transfersInSuspenseAccountId', true);
addControl('interestOnSavingsAccountId', true);
addControl('incomeFromFeeAccountId', true);
addControl('incomeFromPenaltyAccountId', true);
addControl('incomeFromInterestId', true);
addControl('writeOffAccountId', true);
addControl('overdraftPortfolioControlId', false);
if (this.accounting.isAccrualAccountingRuleId(accountingRule)) {
addControl('feesReceivableAccountId', true);
addControl('penaltiesReceivableAccountId', true);
addControl('interestPayableAccountId', true);
addControl('interestReceivableAccountId', false);
} else {
removeControl('feesReceivableAccountId');
removeControl('penaltiesReceivableAccountId');
removeControl('interestPayableAccountId');
removeControl('interestReceivableAccountId');
}
if (isDormancyActive) {
addControl('escheatLiabilityId', false);
} else {
removeControl('escheatLiabilityId');
}
} else {
removeControl('savingsReferenceAccountId');
removeControl('savingsControlAccountId');
removeControl('transfersInSuspenseAccountId');
removeControl('interestOnSavingsAccountId');
removeControl('incomeFromFeeAccountId');
removeControl('incomeFromPenaltyAccountId');
removeControl('incomeFromInterestId');
removeControl('writeOffAccountId');
removeControl('overdraftPortfolioControlId');
removeControl('feesReceivableAccountId');
removeControl('penaltiesReceivableAccountId');
removeControl('interestPayableAccountId');
removeControl('interestReceivableAccountId');
removeControl('escheatLiabilityId');
this.clearOtherGlMappings();
}
}
get savingProductAccounting() {
return this.savingProductAccountingForm.value;
const {
assetsOtherGlMappings,
liabilitiesOtherGlMappings,
expensesOtherGlMappings,
incomeOtherGlMappings,
...savingProductAccounting
} = this.savingProductAccountingForm.value;
const otherGlMappings = this.getOtherGlMappings();
return {
...savingProductAccounting,
...(otherGlMappings ? { otherGlMappings } : {})
};
}
isCashOrAccrualAccounting(): boolean {
return (
this.savingProductAccountingForm.value.accountingRule === 2 ||
this.savingProductAccountingForm.value.accountingRule === 3
);
return this.accounting.isCashOrAccrualAccountingRuleId(this.savingProductAccountingForm.value.accountingRule);
}
isAccrualAccounting(): boolean {
return this.savingProductAccountingForm.value.accountingRule === 3;
return this.accounting.isAccrualAccountingRuleId(this.savingProductAccountingForm.value.accountingRule);
}
buildAccountingSectionConfigs() {
this.accountingSectionConfigs = [
{
key: 'assets',
title: 'Assets',
pillLabel: 'Asset',
pillClass: 'green',
rows: [
{
key: 'savingsReferenceAccountId',
label: 'Savings Reference Account',
inputLabel: 'Savings reference account',
controlName: 'savingsReferenceAccountId',
options: this.assetAccountOptions,
required: true
},
{
key: 'overdraftPortfolioControlId',
label: 'Overdraft Portfolio Control',
inputLabel: 'Overdraft portfolio control',
controlName: 'overdraftPortfolioControlId',
options: this.assetAccountOptions
},
{
key: 'interestReceivableAccountId',
label: 'Interest Receivable Account',
inputLabel: 'Interest receivable account',
controlName: 'interestReceivableAccountId',
options: this.assetAccountOptions,
accrualOnly: true
},
{
key: 'feesReceivableAccountId',
label: 'Fees Receivable Account',
inputLabel: 'Fees receivable account',
controlName: 'feesReceivableAccountId',
options: this.assetAccountOptions,
accrualOnly: true
},
{
key: 'penaltiesReceivableAccountId',
label: 'Penalties Receivable Account',
inputLabel: 'Penalties receivable account',
controlName: 'penaltiesReceivableAccountId',
options: this.assetAccountOptions,
accrualOnly: true
}
],
otherMappingsControlName: 'assetsOtherGlMappings',
otherAccountOptions: this.assetAccountOptions
},
{
key: 'liabilities',
title: 'Liabilities',
pillLabel: 'Liability',
pillClass: 'green',
rows: [
{
key: 'savingsControlAccountId',
label: 'Savings Control Account',
inputLabel: 'Savings control account',
controlName: 'savingsControlAccountId',
options: this.liabilityAccountOptions,
required: true
},
{
key: 'transfersInSuspenseAccountId',
label: 'Transfers In Suspense Account',
inputLabel: 'Transfers in suspense account',
controlName: 'transfersInSuspenseAccountId',
options: this.liabilityAccountOptions,
required: true
},
{
key: 'interestPayableAccountId',
label: 'Interest Payable Account',
inputLabel: 'Interest payable account',
controlName: 'interestPayableAccountId',
options: this.liabilityAccountOptions,
accrualOnly: true
},
{
key: 'escheatLiabilityId',
label: 'Escheat Liability Account',
inputLabel: 'Escheat liability account',
controlName: 'escheatLiabilityId',
options: this.liabilityAccountOptions,
dormancyOnly: true
}
],
otherMappingsControlName: 'liabilitiesOtherGlMappings',
otherAccountOptions: this.liabilityAccountOptions
},
{
key: 'expenses',
title: 'Expenses',
pillLabel: 'Expense',
pillClass: 'amber',
rows: [
{
key: 'interestOnSavingsAccountId',
label: 'Interest On Savings Account',
inputLabel: 'Interest on savings account',
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
},
{
key: 'writeOffAccountId',
label: 'Write Off Account',
inputLabel: 'Write off account',
controlName: 'writeOffAccountId',
options: this.expenseAccountOptions,
required: true
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
otherAccountOptions: this.expenseAccountOptions
},
{
key: 'income',
title: 'Income',
pillLabel: 'Income',
pillClass: 'blue',
rows: [
{
key: 'incomeFromFeeAccountId',
label: 'Income From Fees',
inputLabel: 'Income from fees',
controlName: 'incomeFromFeeAccountId',
options: this.incomeAccountOptions,
required: true
},
{
key: 'incomeFromPenaltyAccountId',
label: 'Income From Penalties',
inputLabel: 'Income from penalties',
controlName: 'incomeFromPenaltyAccountId',
options: this.incomeAccountOptions,
required: true
},
{
key: 'incomeFromInterestId',
label: 'Income From Interest',
inputLabel: 'Income from interest',
controlName: 'incomeFromInterestId',
options: this.incomeAccountOptions,
required: true
}
],
otherMappingsControlName: 'incomeOtherGlMappings',
otherAccountOptions: this.incomeAccountOptions
}
];
}
}
@@ -65,7 +65,7 @@ $gray-900: #202124;
box-shadow: 0 1px 3px rgba($primary, 0.3) !important;
}
@media (max-width: 600px) {
@media (width <= 600px) {
flex-direction: column;
&__select {
@@ -10,8 +10,9 @@
<div class="spc-section">
<div class="spc-section__header">
<i class="fas fa-coins"></i>
<h3>{{ 'labels.inputs.Currency' | translate }}</h3>
<span>Currency &amp; Account Status Configuration</span>
</div>
<div class="spc-body">
<div class="spc-grid">
<mat-form-field appearance="outline" class="spc-field">
<mat-label>{{ 'labels.inputs.Currency' | translate }}</mat-label>
@@ -26,6 +27,7 @@
</mat-option>
}
</mat-select>
<mat-hint>Default: Indian Rupee - auto-set for domestic products</mat-hint>
<mat-error>
{{ 'labels.inputs.Currency' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
@@ -34,50 +36,43 @@
<mat-form-field appearance="outline" class="spc-field">
<mat-label>{{ 'labels.inputs.Decimal Places' | translate }}</mat-label>
<input
type="number"
min="0"
matInput
matTooltip="{{ 'tooltips.Track and report saving account' | translate }}"
<mat-select
formControlName="digitsAfterDecimal"
matTooltip="{{ 'tooltips.Track and report saving account' | translate }}"
required
/>
>
@for (places of decimalPlacesData; track places) {
<mat-option [value]="places">{{ places }}</mat-option>
}
</mat-select>
<mat-error>
{{ 'labels.inputs.Decimal Places' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
</div>
<div class="spc-alert">
<div class="spc-alert__content">
<h4>Dormancy Alert</h4>
<p>Enable automated notifications when an account becomes inactive based on product rules.</p>
</div>
<mat-radio-group class="spc-alert__choices" formControlName="dormancyAlert">
<mat-radio-button [value]="true">yes</mat-radio-button>
<mat-radio-button [value]="false">no</mat-radio-button>
</mat-radio-group>
</div>
<div class="spc-section">
<div class="spc-section__header">
<i class="fas fa-calculator"></i>
<h3>{{ 'labels.inputs.Multiples' | translate }}</h3>
<div class="spc-alert">
<div class="spc-alert__content">
<h4>Nomination Facility</h4>
<p>Allow account holders to designate individuals to receive funds in the event of account holder's death.</p>
</div>
<div class="spc-grid">
<div class="spc-field--full spc-checkbox-row">
<mat-checkbox labelPosition="before" formControlName="setMultiples">
{{ 'labels.inputs.Set the saving installment in multiples of' | translate }}
</mat-checkbox>
<mat-radio-group class="spc-alert__choices" formControlName="nominationFacility">
<mat-radio-button [value]="true">yes</mat-radio-button>
<mat-radio-button [value]="false">no</mat-radio-button>
</mat-radio-group>
</div>
@if (savingProductCurrencyForm.value.setMultiples) {
<mat-form-field appearance="outline" class="spc-field">
<mat-label>{{ 'labels.inputs.Currency in multiples of' | translate }}</mat-label>
<input
type="number"
min="1"
matInput
matTooltip="{{ 'tooltips.Amount to be rounded off' | translate }}"
formControlName="inMultiplesOf"
/>
<mat-error>
{{ 'labels.inputs.Currency in multiples of' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
}
</div>
</div>
</form>
@@ -44,27 +44,71 @@ $gray-900: #202124;
}
}
.spc-section__badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: #eff6ff;
font-size: 14px;
}
.spc-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
@media (max-width: 600px) {
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.spc-body {
background: #fff;
}
.spc-field {
width: 100%;
&--full {
grid-column: 1 / -1;
}
}
.spc-checkbox-row {
.spc-alert {
display: flex;
align-items: center;
padding: 4px 0;
justify-content: space-between;
gap: 24px;
padding: 18px 24px 24px;
border-top: 1px solid $border;
background: #f8fafc;
@media (width <= 760px) {
flex-direction: column;
align-items: flex-start;
}
&__content {
h4 {
margin: 0 0 6px;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
p {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #64748b;
max-width: 540px;
}
}
&__choices {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
}
@@ -6,11 +6,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, Input, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Component, OnInit, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatTooltip } from '@angular/material/tooltip';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -21,19 +20,24 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTooltip,
FaIconComponent,
MatCheckbox
MatRadioGroup,
MatRadioButton,
FaIconComponent
]
})
export class SavingProductCurrencyStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private destroyRef = inject(DestroyRef);
@Input() savingProductsTemplate: any;
savingProductCurrencyForm: UntypedFormGroup;
currencyData: any;
decimalPlacesData = [
2,
0,
3
];
constructor() {
this.createSavingProductCurrencyForm();
@@ -43,13 +47,11 @@ export class SavingProductCurrencyStepComponent implements OnInit {
this.currencyData = this.savingProductsTemplate.currencyOptions;
this.savingProductCurrencyForm.patchValue({
currencyCode: this.savingProductsTemplate.currency.code || this.currencyData[0].code,
digitsAfterDecimal: this.savingProductsTemplate.digitsAfterDecimal ?? '',
setMultiples: !!this.savingProductsTemplate.inMultiplesOf,
inMultiplesOf: this.savingProductsTemplate.inMultiplesOf ?? ''
currencyCode: this.savingProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '',
digitsAfterDecimal: this.savingProductsTemplate.digitsAfterDecimal ?? 2,
dormancyAlert: this.savingProductsTemplate.isDormancyTrackingActive ?? false,
nominationFacility: this.savingProductsTemplate.nominationFacility ?? false
});
this.setupConditionalValidation();
}
createSavingProductCurrencyForm() {
@@ -59,39 +61,14 @@ export class SavingProductCurrencyStepComponent implements OnInit {
Validators.required
],
digitsAfterDecimal: [
'',
2,
[
Validators.required,
Validators.min(0)
]
],
setMultiples: [false],
inMultiplesOf: ['']
});
}
setupConditionalValidation() {
const inMultiplesOfControl = this.savingProductCurrencyForm.get('inMultiplesOf');
const setMultiplesControl = this.savingProductCurrencyForm.get('setMultiples');
if (setMultiplesControl?.value) {
inMultiplesOfControl?.setValidators([
Validators.required,
Validators.min(1)
]);
inMultiplesOfControl?.updateValueAndValidity();
}
setMultiplesControl?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((checked) => {
if (checked) {
inMultiplesOfControl?.setValidators([
Validators.required,
Validators.min(1)
]);
} else {
inMultiplesOfControl?.clearValidators();
inMultiplesOfControl?.setValue('');
}
inMultiplesOfControl?.updateValueAndValidity();
dormancyAlert: [false],
nominationFacility: [false]
});
}
@@ -99,13 +76,12 @@ export class SavingProductCurrencyStepComponent implements OnInit {
const formValue = this.savingProductCurrencyForm.value;
const result: any = {
currencyCode: formValue.currencyCode,
digitsAfterDecimal: formValue.digitsAfterDecimal
digitsAfterDecimal: formValue.digitsAfterDecimal,
dormancyAlert: formValue.dormancyAlert,
isDormancyTrackingActive: formValue.dormancyAlert,
nominationFacility: formValue.nominationFacility
};
if (formValue.inMultiplesOf !== '' && formValue.inMultiplesOf !== null && formValue.inMultiplesOf !== undefined) {
result.inMultiplesOf = formValue.inMultiplesOf;
}
return result;
}
}
@@ -54,4 +54,64 @@
</mat-form-field>
</div>
</div>
<div class="spd-section">
<div class="spd-section__header">
<i class="fas fa-tags"></i>
<h3>Product Classification</h3>
</div>
<div class="spd-grid spd-grid--classification">
<mat-form-field appearance="outline" class="spd-field">
<mat-label>Account Type</mat-label>
<mat-select formControlName="accountType" required>
@for (accountType of accountTypeData; track accountType) {
<mat-option [value]="accountType">{{ accountType }}</mat-option>
}
</mat-select>
<mat-error>Account Type <strong>is required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="spd-field">
<mat-label>Customer Segment</mat-label>
<mat-select formControlName="customerSegment">
@for (customerSegment of customerSegmentData; track customerSegment) {
<mat-option [value]="customerSegment">{{ customerSegment }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="spd-field">
<mat-label>Product Code</mat-label>
<input matInput formControlName="productCode" />
</mat-form-field>
<mat-form-field appearance="outline" class="spd-field" (click)="effectiveToPicker.open()">
<mat-label>Effective To</mat-label>
<input matInput [matDatepicker]="effectiveToPicker" formControlName="effectiveTo" />
<mat-datepicker-toggle matSuffix [for]="effectiveToPicker"></mat-datepicker-toggle>
<mat-datepicker #effectiveToPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="spd-field" (click)="endDatePicker.open()">
<mat-label>End Date</mat-label>
<input matInput [matDatepicker]="endDatePicker" formControlName="endDate" />
<mat-datepicker-toggle matSuffix [for]="endDatePicker"></mat-datepicker-toggle>
<mat-datepicker #endDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="spd-field">
<mat-label>Introducer Required</mat-label>
<mat-select formControlName="introducerRequired">
@for (option of yesNoOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<div class="spd-field spd-field--full spd-checkbox-row">
<mat-checkbox formControlName="shareLinkingEnabled">Share Linking Enabled</mat-checkbox>
</div>
</div>
</div>
</form>
@@ -46,11 +46,15 @@ $gray-900: #202124;
gap: 4px 16px;
padding: 16px 24px;
@media (max-width: 600px) {
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.spd-grid--classification {
grid-template-columns: repeat(3, 1fr);
}
.spd-field {
width: 100%;
@@ -58,3 +62,9 @@ $gray-900: #202124;
grid-column: 1 / -1;
}
}
.spd-checkbox-row {
display: flex;
align-items: center;
min-height: 56px;
}
@@ -6,11 +6,13 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, Input, inject } from '@angular/core';
import { Component, OnInit, OnChanges, Input, SimpleChanges, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatTooltip } from '@angular/material/tooltip';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
@@ -24,12 +26,29 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FaIconComponent
]
})
export class SavingProductDetailsStepComponent implements OnInit {
export class SavingProductDetailsStepComponent implements OnInit, OnChanges {
private formBuilder = inject(UntypedFormBuilder);
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
@Input() savingProductsTemplate: any;
savingProductDetailsForm: UntypedFormGroup;
accountTypeData: string[] = [];
customerSegmentData: string[] = [];
yesNoOptions: Array<{
value: boolean;
label: string;
}> = [
{
value: true,
label: 'Yes'
},
{
value: false,
label: 'No'
}
];
constructor() {
this.createSavingProductDetailsForm();
@@ -37,11 +56,13 @@ export class SavingProductDetailsStepComponent implements OnInit {
ngOnInit() {
if (this.savingProductsTemplate) {
this.savingProductDetailsForm.patchValue({
name: this.savingProductsTemplate.name,
shortName: this.savingProductsTemplate.shortName,
description: this.savingProductsTemplate.description
});
this.applyTemplate(this.savingProductsTemplate);
}
}
ngOnChanges(changes: SimpleChanges) {
if (changes.savingProductsTemplate?.currentValue) {
this.applyTemplate(changes.savingProductsTemplate.currentValue);
}
}
@@ -55,11 +76,111 @@ export class SavingProductDetailsStepComponent implements OnInit {
'',
Validators.required
],
description: ['']
description: [''],
accountType: [
null,
Validators.required
],
customerSegment: [null],
productCode: ['SB-IND-001'],
effectiveDate: [''],
effectiveTo: [''],
endDate: [''],
introducerRequired: [false],
shareLinkingEnabled: [false],
nominationFacility: [false],
dormancyAlert: [false]
});
}
private getOptionNames(options: any[]): string[] {
if (!Array.isArray(options)) {
return [];
}
return options
.map((option) => option?.name ?? option?.value ?? option)
.filter((value) => typeof value === 'string' && value.trim().length > 0);
}
private applyTemplate(template: any) {
const accountTypeOptions = this.getOptionNames(template.accountTypeOptions);
const customerSegmentOptions = this.getOptionNames(template.customerSegmentOptions);
const yesNoOptions = this.getYesNoOptions(template.yesNoOptions);
this.accountTypeData = accountTypeOptions;
this.customerSegmentData = customerSegmentOptions;
if (yesNoOptions.length) {
this.yesNoOptions = yesNoOptions;
}
// Use existing product data if available (when editing), otherwise use template defaults
const isEditing = !!template.id;
this.savingProductDetailsForm.patchValue({
name: isEditing ? template.name : template.name || '',
shortName: isEditing ? template.shortName : template.shortName || '',
description: template.description,
accountType: template.accountType ?? this.accountTypeData[0] ?? null,
customerSegment: template.customerSegment ?? this.customerSegmentData[0] ?? null,
productCode: template.productCode ?? 'SB-IND-001',
effectiveDate: template.effectiveDate ? new Date(template.effectiveDate) : '',
effectiveTo: template.effectiveTo ? new Date(template.effectiveTo) : '',
endDate: template.endDate ? new Date(template.endDate) : '',
introducerRequired: template.introducerRequired === true || template.introducerRequired === 'Yes',
shareLinkingEnabled: !!template.shareLinkingEnabled,
nominationFacility: !!template.nominationFacility,
dormancyAlert: !!template.dormancyAlert
});
}
private getYesNoOptions(options: any[]): Array<{ value: boolean; label: string }> {
if (!Array.isArray(options)) {
return [];
}
return options
.map((option) => {
const rawValue = option?.name ?? option?.value ?? option;
const normalized = typeof rawValue === 'string' ? rawValue.trim().toUpperCase() : rawValue;
if (normalized === 'YES') {
return {
value: true,
label: 'Yes'
};
}
if (normalized === 'NO') {
return {
value: false,
label: 'No'
};
}
return null;
})
.filter((value) => !!value);
}
get savingProductDetails() {
return this.savingProductDetailsForm.value;
const formValue = { ...this.savingProductDetailsForm.value };
const dateFormat = this.settingsService.dateFormat;
const prevEffectiveDate: Date = this.savingProductDetailsForm.value.effectiveDate;
const prevEffectiveTo: Date = this.savingProductDetailsForm.value.effectiveTo;
const prevEndDate: Date = this.savingProductDetailsForm.value.endDate;
if (formValue.effectiveDate instanceof Date) {
formValue.effectiveDate = this.dateUtils.formatDate(prevEffectiveDate, dateFormat) || '';
}
if (formValue.effectiveTo instanceof Date) {
formValue.effectiveTo = this.dateUtils.formatDate(prevEffectiveTo, dateFormat) || '';
}
if (formValue.endDate instanceof Date) {
formValue.endDate = this.dateUtils.formatDate(prevEndDate, dateFormat) || '';
}
return formValue;
}
}
@@ -0,0 +1,351 @@
<!--
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="spl-section">
<div class="spl-section__header">
<span class="spl-section__icon">
<i class="fas fa-chart-bar"></i>
</span>
<div class="spl-section__title">Transaction &amp; Product Limits</div>
<div class="spl-section__badge">R&amp;D BASED DEFAULTS</div>
</div>
<div class="spl-section__body">
<div class="spl-features">
<div class="spl-features__header">Account Features (as per RBI guidelines)</div>
<div class="spl-features__grid">
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-automobile"></i>
ATM Facility
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.atmFacility" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-wallet"></i>
ATM Withdrawal
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.atmWithdrawalFacility"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-mobile-alt"></i>
UPI Facility
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.upiFacility" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-exchange-alt"></i>
POS Transactions
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.posTransactionsFacility"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-book"></i>
Cheque Book
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.chequeBook" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-bolt"></i>
IMPS Facility
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.impsFacility" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-sms"></i>
SMS Alerts
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.smsAlerts" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-bell"></i>
BBPS Facility
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.bbpsFacility" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-shield-alt"></i>
Insurance Cover
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.insuranceCover" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-bolt"></i>
BEPG Facility
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.bepgFacility" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-money-check-alt"></i>
NEFT Facility
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.neftFacility" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-money-check"></i>
RTGS Facility
</span>
<select class="spl-select" [(ngModel)]="accountFacilityConfig.rtgsFacility" (ngModelChange)="markAsDirty()">
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-balance-scale"></i>
Inter-Bank Total Cap
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.interBankTotalCapFacility"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-arrow-right-arrow-left"></i>
Withdrawal Facility
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.withdrawalFacility"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-id-card"></i>
Deposit Facility (KYC)
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.depositFacilityKyc"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-credit-card"></i>
Virtual Debit Card
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.virtualDebitCard"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
</div>
</div>
<div class="spl-table-wrap">
<table class="spl-table">
<colgroup>
<col class="spl-table__col--label" />
<col class="spl-table__col--value" />
<col class="spl-table__col--value" />
<col class="spl-table__col--value" />
</colgroup>
<thead>
<tr>
<th>Limit Type</th>
<th>General Limit (Daily)</th>
<th>Minor Account Limit</th>
<th>Small Saving Limit</th>
</tr>
</thead>
<tbody>
@for (row of accountFacilityLimits; track row.limitType) {
<tr>
<td>{{ row.limitType }}</td>
<td>
<input type="text" class="spl-input" [(ngModel)]="row.generalLimit" (ngModelChange)="markAsDirty()" />
</td>
<td>
<input
type="text"
class="spl-input"
[(ngModel)]="row.minorAccountLimit"
(ngModelChange)="markAsDirty()"
[disabled]="row.limitType === 'RTGS Limit (Min 2L)'"
/>
</td>
<td>
<input
type="text"
class="spl-input"
[(ngModel)]="row.smallSavingLimit"
(ngModelChange)="markAsDirty()"
[disabled]="row.limitType === 'RTGS Limit (Min 2L)'"
/>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="spl-table-wrap spl-table-wrap--kyc">
<h4 class="mat-h4">Deposit Limit (KYC)</h4>
<table class="spl-table">
<thead>
<tr>
<th>Account Type</th>
<th>KYC Type</th>
<th>Limit</th>
</tr>
</thead>
<tbody>
<tr>
<td>General</td>
<td>
<select class="spl-select" [(ngModel)]="depositLimitKyc.general.kycType" (ngModelChange)="markAsDirty()">
<option>Full KYC</option>
<option>Min KYC</option>
<option>No KYC</option>
</select>
</td>
<td>
<input
type="text"
class="spl-input"
[(ngModel)]="depositLimitKyc.general.limit"
(ngModelChange)="markAsDirty()"
/>
</td>
</tr>
<tr>
<td>Minor</td>
<td>
<select class="spl-select" [(ngModel)]="depositLimitKyc.minor.kycType" (ngModelChange)="markAsDirty()">
<option>Guardian KYC</option>
<option>Full KYC</option>
<option>Min KYC</option>
</select>
</td>
<td>
<input
type="number"
class="spl-input"
[(ngModel)]="depositLimitKyc.minor.limit"
(ngModelChange)="markAsDirty()"
/>
</td>
</tr>
<tr>
<td>Small Saving</td>
<td>
<select
class="spl-select"
[(ngModel)]="depositLimitKyc.smallSaving.kycType"
(ngModelChange)="markAsDirty()"
>
<option>Min KYC</option>
<option>Full KYC</option>
<option>Guardian KYC</option>
</select>
</td>
<td>
<input
type="number"
class="spl-input"
[(ngModel)]="depositLimitKyc.smallSaving.limit"
(ngModelChange)="markAsDirty()"
/>
</td>
</tr>
</tbody>
</table>
</div>
<div class="spl-note">
<strong>Note:</strong>
Small Saving Account limits are strictly capped at Rs.1,00,000 maximum balance and Rs.10,000 monthly withdrawal
per RBI Master Circular. Minor accounts are restricted from RTGS and high-risk digital products (BEPG/BBPS) by
default.
</div>
</div>
</div>
@@ -0,0 +1,257 @@
/**
* 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/.
*/
$border: #d9e2ec;
$border-strong: #c7d2de;
$text: #24364b;
$muted: #64748b;
$header-bg: #f8fafc;
$highlight-bg: #eff6ff;
.spl-section {
border: 1px solid $border;
border-radius: 12px;
overflow: hidden;
background: #fff;
}
.spl-section__header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: $header-bg;
border-bottom: 1px solid $border;
}
.spl-section__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #eff6ff;
color: #2563eb;
font-size: 14px;
flex: 0 0 auto;
}
.spl-section__title {
flex: 1 1 auto;
font-size: 16px;
font-weight: 600;
color: $text;
}
.spl-section__badge {
flex: 0 0 auto;
padding: 5px 10px;
border-radius: 999px;
background: #dbeafe;
color: #1d4ed8;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
white-space: nowrap;
}
.spl-section__body {
padding: 18px 20px 20px;
}
.spl-subsection {
border: 1px solid $border;
border-radius: 12px;
overflow: hidden;
background: #fff;
& + & {
margin-top: 16px;
}
}
.spl-subsection__header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: $header-bg;
border-bottom: 1px solid $border;
color: $text;
font-size: 14px;
font-weight: 600;
}
.spl-subsection__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
background: #eff6ff;
color: #2563eb;
font-size: 13px;
flex: 0 0 auto;
}
.spl-subsection__body {
padding: 16px 16px 18px;
}
.spl-table-wrap {
overflow-x: auto;
}
.spl-table {
width: 100%;
min-width: 0;
table-layout: fixed;
border-collapse: collapse;
border: 1px solid $border;
col.spl-table__col--label {
width: 22%;
}
col.spl-table__col--value {
width: 26%;
}
th,
td {
border: 1px solid $border;
padding: 6px 8px;
vertical-align: middle;
}
th {
background: #f8fafc;
color: #64748b;
text-align: center;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
white-space: normal;
word-break: break-word;
}
td:first-child {
background: #f8fafc;
font-weight: 600;
color: $text;
white-space: nowrap;
}
td {
font-size: 13px;
overflow: hidden;
}
}
.spl-table__highlight td:first-child {
color: #1d4ed8;
}
.spl-input,
.spl-select {
width: 100%;
box-sizing: border-box;
font: inherit;
}
.spl-input {
height: 30px;
border: 1px solid $border-strong;
border-radius: 2px;
padding: 0 8px;
background: #fff;
}
.spl-input:disabled,
.spl-select:disabled {
background: #f8fafc;
color: $muted;
}
.spl-select {
height: 30px;
border: 1px solid $border-strong;
border-radius: 2px;
padding: 0 6px;
background: #fff;
}
.spl-input--stacked {
margin-top: 6px;
}
.spl-input--disabled {
background: #f8fafc;
color: $muted;
}
.spl-table__muted {
font-size: 11px;
color: #94a3b8;
letter-spacing: 0.03em;
}
.spl-note {
margin-top: 16px;
padding: 12px 14px;
border: 1px solid $border;
border-radius: 8px;
background: #f8fafc;
color: $muted;
font-size: 11px;
line-height: 1.6;
}
.spl-features {
margin-bottom: 16px;
}
.spl-features__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px 16px;
@media (width <= 900px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (width <= 640px) {
grid-template-columns: 1fr;
}
}
.spl-feature {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: $text;
}
.spl-feature__label {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 18px;
font-weight: 600;
color: #334155;
i {
color: #2563eb;
font-size: 12px;
width: 14px;
text-align: center;
}
}
@@ -0,0 +1,250 @@
/**
* 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, Input, OnChanges, SimpleChanges } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type BooleanFeatureKey =
| 'atmFacility'
| 'atmWithdrawalFacility'
| 'upiFacility'
| 'posTransactionsFacility'
| 'chequeBook'
| 'impsFacility'
| 'smsAlerts'
| 'bbpsFacility'
| 'insuranceCover'
| 'bepgFacility'
| 'neftFacility'
| 'rtgsFacility'
| 'interBankTotalCapFacility'
| 'withdrawalFacility'
| 'depositFacilityKyc'
| 'virtualDebitCard';
interface FacilityLimitRow {
limitType: string;
generalLimit: string | number | null;
minorAccountLimit: string | number | null;
smallSavingLimit: string | number | null;
}
@Component({
selector: 'mifosx-saving-product-limits-step',
templateUrl: './saving-product-limits-step.component.html',
styleUrls: ['./saving-product-limits-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class SavingProductLimitsStepComponent implements OnChanges {
@Input() savingProductsTemplate: any;
pristine = true;
yesNoOptions: Array<{
value: boolean;
label: string;
}> = [
{
value: true,
label: 'Yes'
},
{
value: false,
label: 'No'
}
];
accountFacilityConfig: Record<BooleanFeatureKey, boolean> = {
atmFacility: true,
atmWithdrawalFacility: true,
upiFacility: true,
posTransactionsFacility: true,
chequeBook: true,
impsFacility: true,
smsAlerts: true,
bbpsFacility: true,
insuranceCover: false,
bepgFacility: true,
neftFacility: true,
rtgsFacility: true,
interBankTotalCapFacility: true,
withdrawalFacility: true,
depositFacilityKyc: true,
virtualDebitCard: false
};
accountFacilityLimits: FacilityLimitRow[] = [
{
limitType: 'ATM Withdrawal',
generalLimit: 40000,
minorAccountLimit: 5000,
smallSavingLimit: 10000
},
{
limitType: 'POS Transactions',
generalLimit: 100000,
minorAccountLimit: 2000,
smallSavingLimit: 10000
},
{
limitType: 'UPI Limit',
generalLimit: 50000,
minorAccountLimit: 1000,
smallSavingLimit: 5000
},
{
limitType: 'IMPS Limit',
generalLimit: 50000,
minorAccountLimit: 100,
smallSavingLimit: 5000
},
{
limitType: 'BBPS',
generalLimit: 25000,
minorAccountLimit: 0,
smallSavingLimit: 2000
},
{
limitType: 'BEPG',
generalLimit: 25000,
minorAccountLimit: 0,
smallSavingLimit: 2000
},
{
limitType: 'NEFT Limit',
generalLimit: 1000000,
minorAccountLimit: 25000,
smallSavingLimit: 10000
},
{
limitType: 'RTGS Limit (Min 2L)',
generalLimit: 5000000,
minorAccountLimit: 'N/A',
smallSavingLimit: 'N/A'
},
{
limitType: 'Inter-Bank Total Cap',
generalLimit: 5000000,
minorAccountLimit: 25000,
smallSavingLimit: 10000
},
{
limitType: 'Withdrawal Limit',
generalLimit: 'UNLIMITED',
minorAccountLimit: 10000,
smallSavingLimit: 10000
}
];
depositLimitKyc = {
general: {
kycType: 'Full KYC',
limit: 'No Limit'
},
minor: {
kycType: 'Guardian KYC',
limit: 100000
},
smallSaving: {
kycType: 'Min KYC',
limit: 50000
}
};
ngOnChanges(changes: SimpleChanges) {
if (changes.savingProductsTemplate?.currentValue) {
const template = changes.savingProductsTemplate.currentValue;
this.setYesNoOptions(template);
this.populateFromTemplate(template);
}
}
markAsDirty() {
this.pristine = false;
}
private populateFromTemplate(template: any) {
const isEditing = !!template.id;
// Populate accountFacilityConfig from existing product
if (isEditing && template.accountFacilityConfig) {
this.accountFacilityConfig = { ...this.accountFacilityConfig, ...template.accountFacilityConfig };
}
// Populate accountFacilityLimits from existing product
if (isEditing && template.accountFacilityLimits && template.accountFacilityLimits.length > 0) {
this.accountFacilityLimits = template.accountFacilityLimits.map((limit: any) => ({
limitType: limit.limitType || '',
generalLimit: limit.generalLimit ?? null,
minorAccountLimit: limit.minorAccountLimit ?? null,
smallSavingLimit: limit.smallSavingLimit ?? null
}));
}
// Populate depositLimitKyc from existing product
if (isEditing && template.depositLimitKyc) {
this.depositLimitKyc = {
general: {
kycType: template.depositLimitKyc.general?.kycType || 'Full KYC',
limit: template.depositLimitKyc.general?.limit || 'No Limit'
},
minor: {
kycType: template.depositLimitKyc.minor?.kycType || 'Guardian KYC',
limit: template.depositLimitKyc.minor?.limit ?? 100000
},
smallSaving: {
kycType: template.depositLimitKyc.smallSaving?.kycType || 'Min KYC',
limit: template.depositLimitKyc.smallSaving?.limit ?? 50000
}
};
}
}
private setYesNoOptions(template: any) {
const options = Array.isArray(template?.yesNoOptions) ? template.yesNoOptions : [];
const mappedOptions = options
.map((option: any) => {
const rawValue = option?.name ?? option?.value ?? option;
const normalized = typeof rawValue === 'string' ? rawValue.trim().toUpperCase() : rawValue;
if (normalized === 'YES') {
return {
value: true,
label: 'Yes'
};
}
if (normalized === 'NO') {
return {
value: false,
label: 'No'
};
}
return null;
})
.filter((value: any) => !!value);
if (mappedOptions.length) {
this.yesNoOptions = mappedOptions;
}
}
get savingProductLimits() {
return {
accountFacilityConfig: { ...this.accountFacilityConfig },
accountFacilityLimits: this.accountFacilityLimits.map((row) => ({ ...row })),
depositLimitKyc: {
...this.depositLimitKyc
}
};
}
}
@@ -25,25 +25,107 @@
</div>
}
@if (
savingProduct.accountType ||
savingProduct.customerSegment ||
savingProduct.productCode ||
savingProduct.effectiveTo ||
savingProduct.introducerRequired !== undefined ||
savingProduct.shareLinkingEnabled !== undefined
) {
<h3 class="mat-h3 flex-100">Product Classification</h3>
<mat-divider class="flex-100"></mat-divider>
@if (savingProduct.accountType) {
<div class="flex-100 layout-row">
<span class="flex-40">Account Type:</span>
<span class="flex-60">{{ savingProduct.accountType }}</span>
</div>
}
@if (savingProduct.customerSegment) {
<div class="flex-100 layout-row">
<span class="flex-40">Customer Segment:</span>
<span class="flex-60">{{ savingProduct.customerSegment }}</span>
</div>
}
@if (savingProduct.productCode) {
<div class="flex-100 layout-row">
<span class="flex-40">Product Code:</span>
<span class="flex-60">{{ savingProduct.productCode }}</span>
</div>
}
@if (savingProduct.effectiveTo) {
<div class="flex-100 layout-row">
<span class="flex-40">Effective To:</span>
<span class="flex-60">{{ savingProduct.effectiveTo }}</span>
</div>
}
@if (savingProduct.endDate) {
<div class="flex-100 layout-row">
<span class="flex-40">End Date:</span>
<span class="flex-60">{{ savingProduct.endDate }}</span>
</div>
}
@if (savingProduct.introducerRequired !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Introducer Required:</span>
<span class="flex-60">{{
savingProduct.introducerRequired === true || savingProduct.introducerRequired === 'Yes' ? 'Yes' : 'No'
}}</span>
</div>
}
@if (savingProduct.shareLinkingEnabled !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Share Linking Enabled:</span>
<span class="flex-60">{{ savingProduct.shareLinkingEnabled | yesNo }}</span>
</div>
}
}
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Currency' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider>
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Currency' | translate }}:</span>
<span class="flex-60">{{
savingProduct.currencyCode | find: savingProductsTemplate.currencyOptions : 'code' : 'name'
savingProduct.currency?.name ||
savingProduct.currency?.displayLabel ||
(savingProduct.currencyCode | find: savingProductsTemplate?.currencyOptions : 'code' : 'name') ||
savingProduct.currencyCode ||
'-'
}}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Decimal Places' | translate }}:</span>
<span class="flex-60">{{ savingProduct.digitsAfterDecimal ?? savingProduct.currency?.decimalPlaces }}</span>
<span class="flex-60">{{ savingProduct.digitsAfterDecimal ?? savingProduct.currency?.decimalPlaces ?? 2 }}</span>
</div>
@if (savingProduct.inMultiplesOf) {
@if (savingProduct.currency?.inMultiplesOf !== undefined && savingProduct.currency?.inMultiplesOf !== null) {
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Currency in multiples of' | translate }}:</span>
<span class="flex-60">{{ savingProduct.inMultiplesOf }}</span>
<span class="flex-60">{{ savingProduct.currency.inMultiplesOf }}</span>
</div>
}
@if (savingProduct.dormancyAlert !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Dormancy Alert:</span>
<span class="flex-60">{{ savingProduct.dormancyAlert | yesNo }}</span>
</div>
}
@if (savingProduct.nominationFacility !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Nomination Facility:</span>
<span class="flex-60">{{ savingProduct.nominationFacility | yesNo }}</span>
</div>
}
@@ -59,7 +141,7 @@
<span class="flex-40">{{ 'labels.inputs.Interest Compounding Period' | translate }}:</span>
<span class="flex-60">{{
savingProduct.interestCompoundingPeriodType
| find: savingProductsTemplate.interestCompoundingPeriodTypeOptions : 'id' : 'value'
| find: savingProductsTemplate?.interestCompoundingPeriodTypeOptions : 'id' : 'value'
| translateKey: 'catalogs'
}}</span>
</div>
@@ -68,16 +150,16 @@
<span class="flex-40">{{ 'labels.inputs.Interest Posting Period' | translate }}:</span>
<span class="flex-60">{{
savingProduct.interestPostingPeriodType
| find: savingProductsTemplate.interestPostingPeriodTypeOptions : 'id' : 'value'
| find: savingProductsTemplate?.interestPostingPeriodTypeOptions : 'id' : 'value'
| translateKey: 'catalogs'
}}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Interest Calculated using' | translate }}:</span>
<span class="flex-40">{{ 'labels.inputs.Interest Calculation Type' | translate }}:</span>
<span class="flex-60">{{
savingProduct.interestCalculationType
| find: savingProductsTemplate.interestCalculationTypeOptions : 'id' : 'value'
| find: interestCalculationTypeOptionData : 'id' : 'value'
| translateKey: 'catalogs'
}}</span>
</div>
@@ -86,11 +168,56 @@
<span class="flex-40">{{ 'labels.inputs.Days in Year' | translate }}:</span>
<span class="flex-60">{{
savingProduct.interestCalculationDaysInYearType
| find: savingProductsTemplate.interestCalculationDaysInYearTypeOptions : 'id' : 'value'
| find: savingProductsTemplate?.interestCalculationDaysInYearTypeOptions : 'id' : 'value'
| translateKey: 'catalogs'
}}</span>
</div>
@if (savingProduct.effectiveDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Effective Date:</span>
<span class="flex-60">{{ savingProduct.effectiveDate }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Interest Calculate:</span>
<span class="flex-60">{{
savingProduct.interestCalculateSelector
| find: savingProductsTemplate?.interestCalculationTypeOptions : 'id' : 'value'
}}</span>
</div>
@if (savingProduct.averagePeriod) {
<div class="flex-100 layout-row">
<span class="flex-40">Average Period:</span>
<span class="flex-60">{{ savingProduct.averagePeriod }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Auto Interest Post:</span>
<span class="flex-60">{{ savingProduct.autoPost | yesNo }}</span>
</div>
@if (
savingProduct.frequencyFixDay !== undefined &&
savingProduct.frequencyFixDay !== null &&
savingProduct.frequencyFixDay !== ''
) {
<div class="flex-100 layout-row">
<span class="flex-40">Frequency (Fix Day):</span>
<span class="flex-60">{{ savingProduct.frequencyFixDay }}</span>
</div>
}
@if (savingProduct.lastInterestDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Last Interest Date:</span>
<span class="flex-60">{{ savingProduct.lastInterestDate }}</span>
</div>
}
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Settings' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider>
@@ -108,7 +235,8 @@
savingProduct.lockinPeriodFrequency +
' ' +
(savingProduct.lockinPeriodFrequencyType
| find: savingProductsTemplate.lockinPeriodFrequencyTypeOptions : 'id' : 'value') | translateKey: 'catalogs'
| find: savingProductsTemplate?.lockinPeriodFrequencyTypeOptions : 'id' : 'value')
| translateKey: 'catalogs'
}}</span>
</div>
}
@@ -118,6 +246,28 @@
<span class="flex-60">{{ savingProduct.withdrawalFeeForTransfers | yesNo }}</span>
</div>
@if (savingProduct.balanceFrequency) {
<div class="flex-100 layout-row">
<span class="flex-40">Balance Frequency:</span>
<span class="flex-60">{{ savingProduct.balanceFrequency }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Enable Lock-in Period:</span>
<span class="flex-60">{{ savingProduct.enableLockinPeriod | yesNo }}</span>
</div>
@if (savingProduct.enableLockinPeriod && savingProduct.lockinPeriodFrequency) {
<div class="flex-100 layout-row">
<span class="flex-40">Lock-in Period:</span>
<span class="flex-60">
{{ savingProduct.lockinPeriodFrequency }}
{{ getLockinPeriodFrequencyTypeLabel(savingProduct.lockinPeriodFrequencyType) }}
</span>
</div>
}
@if (savingProduct.minBalanceForInterestCalculation) {
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}:</span>
@@ -146,7 +296,7 @@
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Tax Group' | translate }}:</span>
<span class="flex-60">{{
savingProduct.taxGroupId | find: savingProductsTemplate.taxGroupOptions : 'id' : 'name'
savingProduct.taxGroupId | find: savingProductsTemplate?.taxGroupOptions : 'id' : 'name'
}}</span>
</div>
}
@@ -156,6 +306,11 @@
<span class="flex-60">{{ savingProduct.allowOverdraft | yesNo }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Account Closed with Zero Balance in Day Closing:</span>
<span class="flex-60">{{ savingProduct.accountClosedZeroBalanceInDayClosing | yesNo }}</span>
</div>
@if (savingProduct.minOverdraftForInterestCalculation) {
<div class="flex-100 layout-row">
<span class="flex-40"
@@ -195,6 +350,77 @@
</div>
}
<h3 class="mat-h3 flex-100">Account Facilities</h3>
<mat-divider class="flex-100"></mat-divider>
@if (savingProduct.accountFacilityConfig) {
<div class="layout-row-wrap responsive-column flex-100">
@for (feature of accountFacilityFeatureItems; track feature.key) {
<div class="flex-100 layout-row">
<span class="flex-40">{{ feature.label }}:</span>
<span class="flex-60">{{ savingProduct.accountFacilityConfig[feature.key] | yesNo }}</span>
</div>
}
</div>
}
@if (savingProduct.accountFacilityLimits?.length) {
<div class="layout-row-wrap responsive-column flex-100">
<h4 class="mat-h4 flex-100">Account Facility Limits</h4>
<table class="flex-100 mat-elevation-z1" mat-table [dataSource]="savingProduct.accountFacilityLimits">
<ng-container matColumnDef="limitType">
<th mat-header-cell *matHeaderCellDef>Limit Type</th>
<td mat-cell *matCellDef="let row">{{ row.limitType }}</td>
</ng-container>
<ng-container matColumnDef="generalLimit">
<th mat-header-cell *matHeaderCellDef>General Limit</th>
<td mat-cell *matCellDef="let row">{{ row.generalLimit }}</td>
</ng-container>
<ng-container matColumnDef="minorAccountLimit">
<th mat-header-cell *matHeaderCellDef>Minor Account Limit</th>
<td mat-cell *matCellDef="let row">{{ row.minorAccountLimit }}</td>
</ng-container>
<ng-container matColumnDef="smallSavingLimit">
<th mat-header-cell *matHeaderCellDef>Small Saving Limit</th>
<td mat-cell *matCellDef="let row">{{ row.smallSavingLimit }}</td>
</ng-container>
<tr
mat-header-row
*matHeaderRowDef="['limitType', 'generalLimit', 'minorAccountLimit', 'smallSavingLimit']"
></tr>
<tr
mat-row
*matRowDef="let row; columns: ['limitType', 'generalLimit', 'minorAccountLimit', 'smallSavingLimit']"
></tr>
</table>
</div>
}
@if (savingProduct.depositLimitKyc) {
<div class="layout-row-wrap responsive-column flex-100">
<h4 class="mat-h4 flex-100">Deposit Limit (KYC)</h4>
<div class="flex-100 layout-row">
<span class="flex-40">General:</span>
<span class="flex-60"
>{{ savingProduct.depositLimitKyc.general.kycType }} - {{ savingProduct.depositLimitKyc.general.limit }}</span
>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Minor:</span>
<span class="flex-60"
>{{ savingProduct.depositLimitKyc.minor.kycType }} - {{ savingProduct.depositLimitKyc.minor.limit }}</span
>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Small Saving:</span>
<span class="flex-60">
{{ savingProduct.depositLimitKyc.smallSaving.kycType }} -
{{ savingProduct.depositLimitKyc.smallSaving.limit }}
</span>
</div>
</div>
}
@if (savingProduct.charges.length) {
<div class="layout-row-wrap responsive-column flex-100">
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Charges' | translate }}</h3>
@@ -233,9 +459,262 @@
<h3 class="mat-h3 flex-100">{{ 'labels.heading.Accounting' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider>
@if (isCashOrAccrualAccounting()) {
<div class="spa-gl-summary flex-100">
<div class="spa-gl-summary__header">
<h4 class="mat-h4">GL Mapping</h4>
</div>
<mifosx-view-savings-accounting-details [accountingRule]="accountingRule" [accountingMappings]="accountingMappings">
</mifosx-view-savings-accounting-details>
<div class="spa-gl-section">
<h5 class="mat-h5">Assets</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Savings reference account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.savingsReferenceAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Overdraft portfolio control</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.overdraftPortfolioControlId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.interestReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Fees receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.feesReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Penalties receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.penaltiesReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
</div>
<div class="spa-gl-section">
<h5 class="mat-h5">Liabilities</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Savings control account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.savingsControlAccountId,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Transfers in suspense account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.transfersInSuspenseAccountId,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest payable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.interestPayableAccountId,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
@if (savingProduct.isDormancyTrackingActive) {
<div class="spa-gl-row">
<span class="spa-gl-row__label">Escheat liability account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.escheatLiabilityId,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
}
</div>
<div class="spa-gl-section">
<h5 class="mat-h5">Expenses</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest on savings account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.interestOnSavingsAccountId,
savingProductsTemplate?.accountingMappingOptions.expenseAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Write off account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.writeOffAccountId,
savingProductsTemplate?.accountingMappingOptions.expenseAccountOptions
)
}}</span>
</div>
</div>
<div class="spa-gl-section">
<h5 class="mat-h5">Income</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Income from fees</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.incomeFromFeeAccountId,
savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Income from penalties</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.incomeFromPenaltyAccountId,
savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Income from interest</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.incomeFromInterestId,
savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions
)
}}</span>
</div>
</div>
</div>
}
@if (savingProduct.otherGlMappings) {
<div class="spa-other-gl-summary flex-100">
<div class="spa-gl-summary__header">
<h4 class="mat-h4">Other GL Mappings</h4>
</div>
@if (savingProduct.otherGlMappings.assets?.length) {
<table
class="spa-other-gl-table flex-100 mat-elevation-z1"
mat-table
[dataSource]="savingProduct.otherGlMappings.assets"
>
<ng-container matColumnDef="accountName">
<th mat-header-cell *matHeaderCellDef>Asset Account</th>
<td mat-cell *matCellDef="let row">{{ row.accountName }}</td>
</ng-container>
<ng-container matColumnDef="glCode">
<th mat-header-cell *matHeaderCellDef>GL Account</th>
<td mat-cell *matCellDef="let row">
{{
formatGlAccountByCode(row.glCode, savingProductsTemplate?.accountingMappingOptions.assetAccountOptions)
}}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['accountName', 'glCode']"></tr>
<tr mat-row *matRowDef="let row; columns: ['accountName', 'glCode']"></tr>
</table>
}
@if (savingProduct.otherGlMappings.liabilities?.length) {
<table
class="spa-other-gl-table flex-100 mat-elevation-z1"
mat-table
[dataSource]="savingProduct.otherGlMappings.liabilities"
>
<ng-container matColumnDef="accountName">
<th mat-header-cell *matHeaderCellDef>Liability Account</th>
<td mat-cell *matCellDef="let row">{{ row.accountName }}</td>
</ng-container>
<ng-container matColumnDef="glCode">
<th mat-header-cell *matHeaderCellDef>GL Account</th>
<td mat-cell *matCellDef="let row">
{{
formatGlAccountByCode(
row.glCode,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['accountName', 'glCode']"></tr>
<tr mat-row *matRowDef="let row; columns: ['accountName', 'glCode']"></tr>
</table>
}
@if (savingProduct.otherGlMappings.expenses?.length) {
<table
class="spa-other-gl-table flex-100 mat-elevation-z1"
mat-table
[dataSource]="savingProduct.otherGlMappings.expenses"
>
<ng-container matColumnDef="accountName">
<th mat-header-cell *matHeaderCellDef>Expense Account</th>
<td mat-cell *matCellDef="let row">{{ row.accountName }}</td>
</ng-container>
<ng-container matColumnDef="glCode">
<th mat-header-cell *matHeaderCellDef>GL Account</th>
<td mat-cell *matCellDef="let row">
{{
formatGlAccountByCode(
row.glCode,
savingProductsTemplate?.accountingMappingOptions.expenseAccountOptions
)
}}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['accountName', 'glCode']"></tr>
<tr mat-row *matRowDef="let row; columns: ['accountName', 'glCode']"></tr>
</table>
}
@if (savingProduct.otherGlMappings.income?.length) {
<table
class="spa-other-gl-table flex-100 mat-elevation-z1"
mat-table
[dataSource]="savingProduct.otherGlMappings.income"
>
<ng-container matColumnDef="accountName">
<th mat-header-cell *matHeaderCellDef>Income Account</th>
<td mat-cell *matCellDef="let row">{{ row.accountName }}</td>
</ng-container>
<ng-container matColumnDef="glCode">
<th mat-header-cell *matHeaderCellDef>GL Account</th>
<td mat-cell *matCellDef="let row">
{{
formatGlAccountByCode(row.glCode, savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions)
}}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['accountName', 'glCode']"></tr>
<tr mat-row *matRowDef="let row; columns: ['accountName', 'glCode']"></tr>
</table>
}
</div>
}
@if (
savingProduct.advancedAccountingRules &&
@@ -265,7 +744,7 @@
<td mat-cell *matCellDef="let paymentFundSource">
{{
paymentFundSource.fundSourceAccountId
| find: savingProductsTemplate.accountingMappingOptions.assetAccountOptions : 'id' : 'name'
| find: savingProductsTemplate?.accountingMappingOptions.assetAccountOptions : 'id' : 'name'
}}
</td>
</ng-container>
@@ -289,7 +768,7 @@
<td mat-cell *matCellDef="let feesIncome">
{{
feesIncome.incomeAccountId
| find: savingProductsTemplate.accountingMappingOptions.incomeAccountOptions : 'id' : 'name'
| find: savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions : 'id' : 'name'
}}
</td>
</ng-container>
@@ -317,7 +796,7 @@
<td mat-cell *matCellDef="let penaltyIncome">
{{
penaltyIncome.incomeAccountId
| find: savingProductsTemplate.accountingMappingOptions.incomeAccountOptions : 'id' : 'name'
| find: savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions : 'id' : 'name'
}}
</td>
</ng-container>
@@ -329,4 +808,3 @@
</div>
}
</div>
@@ -71,3 +71,95 @@ mat-divider {
.margin-t {
margin-top: 1em;
}
.spa-gl-summary,
.spa-other-gl-summary {
display: flex;
flex-direction: column;
gap: 0.9rem;
margin-top: 1.25rem;
}
.spa-gl-summary__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0 0.5rem;
}
.spa-gl-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.15rem;
border: 1px solid #e3e7f2;
border-radius: 12px;
background: #fff;
}
.spa-gl-section h5 {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #91a0bc;
}
.spa-gl-row {
display: grid;
grid-template-columns: minmax(220px, 40%) 1fr;
gap: 1rem;
align-items: baseline;
padding: 0.2rem 0;
}
.spa-gl-row__label {
color: #1f2f4a;
font-weight: 500;
}
.spa-gl-row__value {
color: #101828;
font-weight: 600;
word-break: break-word;
}
.spa-other-gl-table {
margin-top: 0.5rem;
margin-bottom: 0.75rem;
table-layout: fixed;
width: 100%;
}
.spa-other-gl-table:first-of-type {
margin-top: 0.25rem;
}
.spa-other-gl-summary .mat-elevation-z1 {
border-radius: 10px;
overflow: hidden;
}
.spa-other-gl-table th.mat-mdc-header-cell,
.spa-other-gl-table td.mat-mdc-cell {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.spa-other-gl-table th.mat-mdc-header-cell:first-child,
.spa-other-gl-table td.mat-mdc-cell:first-child {
width: 38%;
}
.spa-other-gl-table th.mat-mdc-header-cell:last-child,
.spa-other-gl-table td.mat-mdc-cell:last-child {
width: 62%;
word-break: break-word;
}
@media (width <= 768px) {
.spa-gl-row {
grid-template-columns: 1fr;
gap: 0.35rem;
}
}
@@ -22,7 +22,6 @@ import {
MatRowDef,
MatRow
} from '@angular/material/table';
import { ViewSavingsAccountingDetailsComponent } from '../../../../shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { FindPipe } from '../../../../pipes/find.pipe';
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
@@ -46,7 +45,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
ViewSavingsAccountingDetailsComponent,
FaIconComponent,
FindPipe,
FormatNumberPipe,
@@ -79,6 +77,25 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
accountingMappings: any = {};
accountingRule: OptionData;
interestCalculationTypeOptionData: any[] = [];
accountFacilityFeatureItems = [
{ key: 'atmFacility', label: 'ATM Facility' },
{ key: 'atmWithdrawalFacility', label: 'ATM Withdrawal Facility' },
{ key: 'upiFacility', label: 'UPI Facility' },
{ key: 'posTransactionsFacility', label: 'POS Transactions Facility' },
{ key: 'chequeBook', label: 'Cheque Book' },
{ key: 'impsFacility', label: 'IMPS Facility' },
{ key: 'smsAlerts', label: 'SMS Alerts' },
{ key: 'bbpsFacility', label: 'BBPS Facility' },
{ key: 'insuranceCover', label: 'Insurance Cover' },
{ key: 'bepgFacility', label: 'BEPG Facility' },
{ key: 'neftFacility', label: 'NEFT Facility' },
{ key: 'rtgsFacility', label: 'RTGS Facility' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
];
ngOnInit() {
this.setCurrentValues();
@@ -89,13 +106,24 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
}
setCurrentValues(): void {
if (!this.savingProduct || !this.savingProductsTemplate) {
return;
}
this.interestCalculationTypeOptionData =
this.savingProductsTemplate.interestCalculationTypeOption ||
this.savingProductsTemplate.interestCalculationTypeOptions ||
[];
this.accountingRule = this.accounting.getAccountingRuleFrom(this.savingProduct.accountingRule);
if (this.isCashOrAccrualAccounting()) {
const assetAccountData = this.savingProductsTemplate.accountingMappingOptions.assetAccountOptions || [];
const incomeAccountData = this.savingProductsTemplate.accountingMappingOptions.incomeAccountOptions || [];
const expenseAccountData = this.savingProductsTemplate.accountingMappingOptions.expenseAccountOptions || [];
const liabilityAccountData = this.savingProductsTemplate.accountingMappingOptions.liabilityAccountOptions || [];
if (!this.isCashOrAccrualAccounting()) {
return;
}
const assetAccountData = this.savingProductsTemplate?.accountingMappingOptions?.assetAccountOptions || [];
const incomeAccountData = this.savingProductsTemplate?.accountingMappingOptions?.incomeAccountOptions || [];
const expenseAccountData = this.savingProductsTemplate?.accountingMappingOptions?.expenseAccountOptions || [];
const liabilityAccountData = this.savingProductsTemplate?.accountingMappingOptions?.liabilityAccountOptions || [];
this.accountingMappings = {
savingsReferenceAccount: this.accounting.glAccountLookUp(
@@ -114,7 +142,6 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
this.savingProduct.transfersInSuspenseAccountId,
liabilityAccountData
),
escheatLiability: this.accounting.glAccountLookUp(this.savingProduct.escheatLiabilityId, liabilityAccountData),
interestOnSavingsAccount: this.accounting.glAccountLookUp(
this.savingProduct.interestOnSavingsAccountId,
expenseAccountData
@@ -132,19 +159,35 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
};
if (this.isAccrualAccounting()) {
this.accountingMappings['feesReceivableAccount'] = this.accounting.glAccountLookUp(
this.accountingMappings = {
...this.accountingMappings,
feeReceivableAccount: this.accounting.glAccountLookUp(
this.savingProduct.feesReceivableAccountId,
assetAccountData
);
this.accountingMappings['penaltiesReceivableAccount'] = this.accounting.glAccountLookUp(
),
penaltyReceivableAccount: this.accounting.glAccountLookUp(
this.savingProduct.penaltiesReceivableAccountId,
assetAccountData
);
this.accountingMappings['interestPayableAccount'] = this.accounting.glAccountLookUp(
),
interestPayableAccount: this.accounting.glAccountLookUp(
this.savingProduct.interestPayableAccountId,
liabilityAccountData
);
),
interestReceivableAccount: this.accounting.glAccountLookUp(
this.savingProduct.interestReceivableAccountId,
assetAccountData
)
};
}
if (this.savingProduct.isDormancyTrackingActive) {
this.accountingMappings = {
...this.accountingMappings,
escheatLiabilityAccount: this.accounting.glAccountLookUp(
this.savingProduct.escheatLiabilityId,
liabilityAccountData
)
};
}
}
@@ -155,4 +198,50 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
isAccrualAccounting(): boolean {
return this.accounting.isAccrualAccounting(this.accountingRule);
}
getBooleanLabel(value: any): string {
return value === true || value === 'Yes' ? 'Yes' : 'No';
}
getLockinPeriodFrequencyTypeLabel(value: any): string {
switch (value) {
case 'DAYS':
return 'Days';
case 'WEEKS':
return 'Weeks';
case 'MONTHS':
return 'Months';
case 'YEARS':
return 'Years';
default:
return value ?? '';
}
}
getOptionLabel(value: any, options: any[], key: string, property: string): string {
if (value === null || value === undefined || !Array.isArray(options)) {
return '-';
}
const match = options.find((option) => option?.[key] === value);
return match?.[property] || '-';
}
formatGlAccountById(glAccountId: number, options: any[]): string {
if (!glAccountId || !Array.isArray(options)) {
return '-';
}
const account = this.accounting.glAccountLookUp(glAccountId, options as any);
return account ? `(${account.glCode}) ${account.name}` : '-';
}
formatGlAccountByCode(glCode: string, options: any[]): string {
if (!glCode || !Array.isArray(options)) {
return '-';
}
const account = options.find((option) => option?.glCode === glCode);
return account ? `(${account.glCode}) ${account.name}` : glCode;
}
}
@@ -1,4 +1,4 @@
<!--
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
@@ -7,326 +7,103 @@
-->
<form [formGroup]="savingProductSettingsForm">
<!-- Opening Balance -->
<div class="sps-section">
<div class="sps-section__header">
<i class="fas fa-wallet"></i>
<h3>{{ 'labels.inputs.Opening Balance' | translate }}</h3>
<span class="sps-section__badge"><i class="fas fa-balance-scale"></i></span>
<span>Balance &amp; Minimum Balance</span>
</div>
<div class="sps-grid">
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Minimum Opening Balance' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Sets the minimum deposit amount' | translate }}"
formControlName="minRequiredOpeningBalance"
min="0"
step="0.01"
/>
@if (savingProductSettingsForm.get('minRequiredOpeningBalance').hasError('min')) {
<mat-error>
{{ 'labels.inputs.Minimum Opening Balance' | translate }} {{ 'labels.commons.must be' | translate }}
{{ 'labels.commons.a positive number' | translate }}
</mat-error>
}
<div class="sps-body">
<div class="sps-layout sps-layout--balance">
<div class="sps-row sps-row--three">
<mat-form-field appearance="outline" class="sps-field sps-field--compact">
<mat-label>Minimum Opening Balance</mat-label>
<input type="number" matInput formControlName="minRequiredOpeningBalance" min="0" step="0.01" />
</mat-form-field>
</div>
</div>
<!-- Lock-in Period -->
<div class="sps-section">
<div class="sps-section__header">
<i class="fas fa-lock"></i>
<h3>{{ 'labels.inputs.Lock-in Period' | translate }}</h3>
</div>
<div class="sps-grid">
<div class="sps-field--full sps-checkbox-row">
<mat-checkbox
labelPosition="before"
matTooltip="{{ 'tooltips.Used to indicate the length of time' | translate }}"
formControlName="enableLockinPeriod"
>
{{ 'labels.inputs.Enable Lock-in Period' | translate }}
<div class="sps-toggle sps-toggle--inline">
<mat-checkbox labelPosition="before" formControlName="enforceMinRequiredBalance">
<span>Enforce Minimum Balance</span>
</mat-checkbox>
</div>
@if (savingProductSettingsForm.value.enableLockinPeriod) {
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Frequency' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.The number at which lock in period occurs' | translate }}"
formControlName="lockinPeriodFrequency"
min="1"
step="1"
required
/>
@if (savingProductSettingsForm.get('lockinPeriodFrequency')?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Frequency' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
@if (savingProductSettingsForm.get('lockinPeriodFrequency')?.hasError('min')) {
<mat-error>{{ 'tooltips.Frequency must be greater than zero' | translate }}</mat-error>
}
@if (savingProductSettingsForm.get('lockinPeriodFrequency')?.hasError('pattern')) {
<mat-error>{{ 'tooltips.Frequency must be a positive integer' | translate }}</mat-error>
<mat-form-field appearance="outline" class="sps-field sps-field--compact">
<mat-label>Minimum Balance (₹)</mat-label>
<input type="number" matInput formControlName="minRequiredBalance" min="0" step="0.01" />
<mat-hint>Penal charge applies on breach</mat-hint>
</mat-form-field>
</div>
<div class="sps-row sps-row--two">
<mat-form-field appearance="outline" class="sps-field sps-field--compact">
<mat-label>Balance Frequency</mat-label>
<mat-select formControlName="balanceFrequency">
@for (balanceFrequency of periodFrequencyData; track balanceFrequency.id) {
<mat-option [value]="balanceFrequency.value">{{ balanceFrequency.value }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Type' | translate }}</mat-label>
<mat-select formControlName="lockinPeriodFrequencyType" required>
<div class="sps-toggle sps-toggle--inline sps-toggle--right">
<mat-checkbox labelPosition="before" formControlName="withdrawalFeeForTransfers">
<span>Apply Withdrawal Fee for Transfers</span>
</mat-checkbox>
</div>
</div>
</div>
</div>
</div>
<div class="sps-section">
<div class="sps-section__header">
<span class="sps-section__badge"><i class="fas fa-lock"></i></span>
<span>Lock-in Period</span>
</div>
<div class="sps-body">
<div class="sps-lockin-row">
<span>Enable Lock-in Period</span>
<mat-checkbox formControlName="enableLockinPeriod"></mat-checkbox>
</div>
@if (savingProductSettingsForm.value.enableLockinPeriod) {
<div class="sps-lockin-fields">
<mat-form-field appearance="outline" class="sps-field sps-field--compact">
<mat-label>Frequency*</mat-label>
<input type="number" matInput formControlName="lockinPeriodFrequency" min="1" step="1" />
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field sps-field--compact">
<mat-label>Type*</mat-label>
<mat-select formControlName="lockinPeriodFrequencyType">
@for (lockinPeriodFrequencyType of lockinPeriodFrequencyTypeData; track lockinPeriodFrequencyType) {
<mat-option [value]="lockinPeriodFrequencyType.id">
{{ lockinPeriodFrequencyType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
@if (savingProductSettingsForm.get('lockinPeriodFrequencyType')?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Type' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
}
</div>
</div>
<!-- Balance & Fees -->
<div class="sps-section">
<div class="sps-section__header">
<i class="fas fa-balance-scale"></i>
<h3>{{ 'labels.inputs.Balance' | translate }} &amp; {{ 'labels.inputs.Fees' | translate }}</h3>
<span class="sps-section__badge"><i class="fas fa-cog"></i></span>
<span>Additional Rules</span>
</div>
<div class="sps-grid">
<div class="sps-field sps-checkbox-row">
<mat-checkbox
labelPosition="before"
matTooltip="{{ 'tooltips.Indicates whether the withdrawal fee' | translate }}"
formControlName="withdrawalFeeForTransfers"
>
{{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}
<div class="sps-body">
<div class="sps-layout sps-layout--additional">
<div class="sps-toggle sps-toggle--inline">
<mat-checkbox formControlName="accountClosedZeroBalanceInDayClosing">
<span>Account Closed with Zero balance in Day Closing</span>
</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Sets the balance required for interest calculation' | translate }}"
formControlName="minBalanceForInterestCalculation"
min="0"
step="0.01"
/>
@if (savingProductSettingsForm.get('minBalanceForInterestCalculation').hasError('min')) {
<mat-error>
{{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}
{{ 'labels.commons.must be' | translate }}
{{ 'labels.commons.a positive number' | translate }}
</mat-error>
}
</mat-form-field>
<div class="sps-field sps-checkbox-row">
<mat-checkbox
labelPosition="before"
matTooltip="{{ 'tooltips.Indicates whether to enforce a minimum balance' | translate }}"
formControlName="enforceMinRequiredBalance"
>
{{ 'labels.inputs.Enforce Minimum Balance' | translate }}
</mat-checkbox>
</div>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Minimum Balance' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Sets the minimum balance allowed for a saving account' | translate }}"
formControlName="minRequiredBalance"
min="0"
step="0.01"
/>
@if (savingProductSettingsForm.get('minRequiredBalance').hasError('min')) {
<mat-error>
{{ 'labels.inputs.Minimum Balance' | translate }} {{ 'labels.commons.must be' | translate }}
{{ 'labels.commons.a positive number' | translate }}
</mat-error>
}
<mat-form-field appearance="outline" class="sps-field sps-field--compact">
<mat-label>Balance Required for Interest Calculation</mat-label>
<input type="number" matInput formControlName="minBalanceForInterestCalculation" min="0" step="0.01" />
</mat-form-field>
</div>
</div>
<!-- Tax -->
<div class="sps-section">
<div class="sps-section__header">
<i class="fas fa-file-invoice-dollar"></i>
<h3>{{ 'labels.inputs.Tax' | translate }}</h3>
</div>
<div class="sps-grid">
<div class="sps-field--full sps-checkbox-row">
<mat-checkbox
labelPosition="before"
matTooltip="{{ 'tooltips.An boolean flag to attach' | translate }}"
formControlName="withHoldTax"
>
{{ 'labels.inputs.Is Withhold Tax Applicable' | translate }}
</mat-checkbox>
</div>
@if (savingProductSettingsForm.value.withHoldTax) {
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Tax Group' | translate }}</mat-label>
<mat-select formControlName="taxGroupId" required>
@for (taxGroup of taxGroupData; track taxGroup) {
<mat-option [value]="taxGroup.id">
{{ taxGroup.name }}
</mat-option>
}
</mat-select>
<mat-error>
{{ 'labels.inputs.Tax Group' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
}
</div>
</div>
<!-- Overdraft -->
<div class="sps-section">
<div class="sps-section__header">
<i class="fas fa-exclamation-triangle"></i>
<h3>{{ 'labels.inputs.Overdraft' | translate }}</h3>
</div>
<div class="sps-grid">
<div class="sps-field--full sps-checkbox-row">
<mat-checkbox
labelPosition="before"
matTooltip="{{ 'tooltips.Indicates whether saving accounts based on this saving product' | translate }}"
formControlName="allowOverdraft"
>
{{ 'labels.inputs.Is Overdraft Allowed' | translate }}
</mat-checkbox>
</div>
@if (savingProductSettingsForm.value.allowOverdraft) {
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Sets the overdraft required for interest calculation' | translate }}"
formControlName="minOverdraftForInterestCalculation"
min="0"
step="0.01"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Default interest rate on overdraft' | translate }}"
formControlName="nominalAnnualInterestRateOverdraft"
min="0"
step="0.01"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Sets the maximum allowed overdraft amount' | translate }}"
formControlName="overdraftLimit"
min="0"
step="0.01"
/>
</mat-form-field>
}
</div>
</div>
<!-- Dormancy Tracking -->
<div class="sps-section">
<div class="sps-section__header">
<i class="fas fa-bed"></i>
<h3>{{ 'labels.inputs.Dormancy Tracking' | translate }}</h3>
</div>
<div class="sps-grid">
<div class="sps-field--full sps-checkbox-row">
<mat-checkbox
labelPosition="before"
matTooltip="{{ 'tooltips.Enables definition and tracking inactive Savings Accounts' | translate }}"
formControlName="isDormancyTrackingActive"
>
{{ 'labels.inputs.Enable Dormancy Tracking' | translate }}
</mat-checkbox>
</div>
@if (savingProductSettingsForm.value.isDormancyTrackingActive) {
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Number of Days to Inactive sub-status' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Inactive period to mark an account as Inactive' | translate }}"
formControlName="daysToInactive"
min="0"
step="1"
required
/>
<mat-error>
{{ 'labels.inputs.Number of Days to Inactive sub-status' | translate }}
{{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Number of Days to Dormant sub-status' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Inactive period to mark an account as Dormant' | translate }}"
formControlName="daysToDormancy"
min="0"
step="1"
required
/>
<mat-error>
{{ 'labels.inputs.Number of Days to Dormant sub-status' | translate }}
{{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>{{ 'labels.inputs.Number of Days to Escheat' | translate }}</mat-label>
<input
type="number"
matInput
matTooltip="{{ 'tooltips.Inactive period to mark an account as Escheat' | translate }}"
formControlName="daysToEscheat"
min="0"
step="1"
required
/>
<mat-error>
{{ 'labels.inputs.Number of Days to Escheat' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
}
</div>
</div>
</form>
@@ -44,15 +44,20 @@ $gray-900: #202124;
}
}
.sps-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
.sps-section__badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: #eff6ff;
font-size: 14px;
}
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
.sps-body {
background: #fff;
padding: 18px 24px 24px;
}
.sps-field {
@@ -61,10 +66,112 @@ $gray-900: #202124;
&--full {
grid-column: 1 / -1;
}
&--compact {
width: 100%;
}
}
.sps-checkbox-row {
.sps-layout {
display: grid;
gap: 16px;
}
.sps-layout--balance {
gap: 22px;
}
.sps-layout--lockin {
grid-template-columns: repeat(2, minmax(0, 1fr));
@media (width <= 640px) {
grid-template-columns: 1fr;
}
}
.sps-layout--additional {
grid-template-columns: repeat(2, minmax(0, 1fr));
@media (width <= 1100px) {
grid-template-columns: 1fr;
}
}
.sps-row {
display: grid;
gap: 18px;
align-items: start;
}
.sps-row--three {
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
.sps-field--compact:first-child {
min-width: 0;
}
.sps-toggle--inline {
align-self: center;
min-height: 56px;
}
}
.sps-row--two {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
.sps-field--compact {
min-width: 0;
}
}
.sps-toggle {
display: flex;
align-items: center;
padding: 4px 0;
min-height: 56px;
mat-checkbox {
font-size: 14px;
color: $gray-900;
}
&--full {
min-height: auto;
margin-bottom: 8px;
}
&--inline {
padding-top: 0;
}
&--right {
justify-content: flex-start;
}
}
.sps-lockin-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid $gray-200;
font-size: 14px;
color: $gray-900;
}
.sps-lockin-row mat-checkbox {
margin-right: 2px;
}
.sps-lockin-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
@media (width <= 640px) {
grid-template-columns: 1fr;
}
}
@@ -1,4 +1,4 @@
/**
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
@@ -7,16 +7,8 @@
*/
import { Component, OnInit, Input, inject } from '@angular/core';
import {
UntypedFormGroup,
UntypedFormBuilder,
UntypedFormControl,
Validators,
ReactiveFormsModule
} from '@angular/forms';
import { MatTooltip } from '@angular/material/tooltip';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
@@ -25,9 +17,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./saving-product-settings-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTooltip,
MatCheckbox,
FaIconComponent
MatCheckbox
]
})
export class SavingProductSettingsStepComponent implements OnInit {
@@ -37,8 +27,26 @@ export class SavingProductSettingsStepComponent implements OnInit {
savingProductSettingsForm: UntypedFormGroup;
lockinPeriodFrequencyTypeData: any;
taxGroupData: any;
periodFrequencyData: any[] = [];
lockinPeriodFrequencyTypeData = [
{
id: 'DAYS',
value: 'value.days'
},
{
id: 'WEEKS',
value: 'value.weeks'
},
{
id: 'MONTHS',
value: 'value.months'
},
{
id: 'YEARS',
value: 'value.years'
}
];
constructor() {
this.createSavingProductSettingsForm();
@@ -46,32 +54,34 @@ export class SavingProductSettingsStepComponent implements OnInit {
}
ngOnInit() {
this.lockinPeriodFrequencyTypeData = this.savingProductsTemplate.lockinPeriodFrequencyTypeOptions;
this.taxGroupData = this.savingProductsTemplate.taxGroupOptions;
const hasLockinPeriod =
this.savingProductsTemplate.lockinPeriodFrequency && this.savingProductsTemplate.lockinPeriodFrequency > 0;
this.periodFrequencyData = this.savingProductsTemplate.periodFrequencyOptions || [];
const defaultPeriod = this.periodFrequencyData[0]?.value || 'Monthly';
const isEditing = !!this.savingProductsTemplate.id;
this.savingProductSettingsForm.patchValue({
minRequiredOpeningBalance: this.savingProductsTemplate.minRequiredOpeningBalance,
enableLockinPeriod: hasLockinPeriod,
withdrawalFeeForTransfers: this.savingProductsTemplate.withdrawalFeeForTransfers,
minBalanceForInterestCalculation: this.savingProductsTemplate.minBalanceForInterestCalculation,
enforceMinRequiredBalance: this.savingProductsTemplate.enforceMinRequiredBalance,
minRequiredBalance: this.savingProductsTemplate.minRequiredBalance,
allowOverdraft: this.savingProductsTemplate.allowOverdraft,
minOverdraftForInterestCalculation: this.savingProductsTemplate.minOverdraftForInterestCalculation,
nominalAnnualInterestRateOverdraft: this.savingProductsTemplate.nominalAnnualInterestRateOverdraft,
overdraftLimit: this.savingProductsTemplate.overdraftLimit,
withHoldTax: this.savingProductsTemplate.withHoldTax,
taxGroupId: this.savingProductsTemplate.taxGroup && this.savingProductsTemplate.taxGroup.id,
isDormancyTrackingActive: this.savingProductsTemplate.isDormancyTrackingActive,
daysToInactive: this.savingProductsTemplate.daysToInactive,
daysToDormancy: this.savingProductsTemplate.daysToDormancy,
daysToEscheat: this.savingProductsTemplate.daysToEscheat
balanceFrequency: isEditing ? this.savingProductsTemplate.balanceFrequency || defaultPeriod : defaultPeriod,
enableLockinPeriod: isEditing
? this.savingProductsTemplate.lockinPeriodFrequency && this.savingProductsTemplate.lockinPeriodFrequency > 0
: this.savingProductsTemplate.lockinPeriodFrequency && this.savingProductsTemplate.lockinPeriodFrequency > 0,
accountClosedZeroBalanceInDayClosing: isEditing
? (this.savingProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false)
: false,
isDormancyTrackingActive: isEditing ? (this.savingProductsTemplate.isDormancyTrackingActive ?? true) : true,
daysToInactive: isEditing ? this.savingProductsTemplate.daysToInactive || 30 : 30,
daysToDormancy: isEditing ? this.savingProductsTemplate.daysToDormancy || 60 : 60,
daysToEscheat: isEditing ? this.savingProductsTemplate.daysToEscheat || 90 : 90
});
if (hasLockinPeriod) {
if (
isEditing &&
this.savingProductsTemplate.lockinPeriodFrequency &&
this.savingProductsTemplate.lockinPeriodFrequency > 0
) {
this.savingProductSettingsForm.patchValue({
lockinPeriodFrequency: this.savingProductsTemplate.lockinPeriodFrequency,
lockinPeriodFrequencyType:
@@ -87,7 +97,6 @@ export class SavingProductSettingsStepComponent implements OnInit {
'',
[Validators.min(0)]
],
enableLockinPeriod: [false],
withdrawalFeeForTransfers: [false],
minBalanceForInterestCalculation: [
'',
@@ -98,95 +107,42 @@ export class SavingProductSettingsStepComponent implements OnInit {
'',
[Validators.min(0)]
],
allowOverdraft: [false],
withHoldTax: [false],
isDormancyTrackingActive: [false]
balanceFrequency: ['Monthly'],
enableLockinPeriod: [false],
lockinPeriodFrequency: [''],
lockinPeriodFrequencyType: [''],
accountClosedZeroBalanceInDayClosing: [false],
// Dormancy tracking fields (required when isDormancyTrackingActive is true)
isDormancyTrackingActive: [true],
daysToInactive: [30],
daysToDormancy: [60],
daysToEscheat: [90]
});
}
setConditionalControls() {
this.savingProductSettingsForm.get('enableLockinPeriod').valueChanges.subscribe((enableLockinPeriod: any) => {
if (enableLockinPeriod) {
this.savingProductSettingsForm.addControl(
'lockinPeriodFrequency',
new UntypedFormControl('', [
this.savingProductSettingsForm.get('lockinPeriodFrequency').setValidators([
Validators.required,
Validators.min(1),
Validators.pattern('^[1-9]\\d*$')
])
);
this.savingProductSettingsForm.addControl(
'lockinPeriodFrequencyType',
new UntypedFormControl('', Validators.required)
);
]);
this.savingProductSettingsForm.get('lockinPeriodFrequencyType').setValidators([
Validators.required
]);
} else {
this.savingProductSettingsForm.removeControl('lockinPeriodFrequency');
this.savingProductSettingsForm.removeControl('lockinPeriodFrequencyType');
}
});
this.savingProductSettingsForm.get('allowOverdraft').valueChanges.subscribe((allowOverdraft: any) => {
if (allowOverdraft) {
this.savingProductSettingsForm.addControl(
'minOverdraftForInterestCalculation',
new UntypedFormControl('', [Validators.min(0)])
);
this.savingProductSettingsForm.addControl(
'nominalAnnualInterestRateOverdraft',
new UntypedFormControl('', [Validators.min(0)])
);
this.savingProductSettingsForm.addControl('overdraftLimit', new UntypedFormControl('', [Validators.min(0)]));
} else {
this.savingProductSettingsForm.removeControl('minOverdraftForInterestCalculation');
this.savingProductSettingsForm.removeControl('nominalAnnualInterestRateOverdraft');
this.savingProductSettingsForm.removeControl('overdraftLimit');
}
});
this.savingProductSettingsForm.get('withHoldTax').valueChanges.subscribe((withHoldTax: any) => {
if (withHoldTax) {
this.savingProductSettingsForm.addControl('taxGroupId', new UntypedFormControl('', Validators.required));
} else {
this.savingProductSettingsForm.removeControl('taxGroupId');
}
});
this.savingProductSettingsForm
.get('isDormancyTrackingActive')
.valueChanges.subscribe((isDormancyTrackingActive: any) => {
if (isDormancyTrackingActive) {
this.savingProductSettingsForm.addControl(
'daysToInactive',
new UntypedFormControl('', [
Validators.required,
Validators.min(0)
])
);
this.savingProductSettingsForm.addControl(
'daysToDormancy',
new UntypedFormControl('', [
Validators.required,
Validators.min(0)
])
);
this.savingProductSettingsForm.addControl(
'daysToEscheat',
new UntypedFormControl('', [
Validators.required,
Validators.min(0)
])
);
} else {
this.savingProductSettingsForm.removeControl('daysToInactive');
this.savingProductSettingsForm.removeControl('daysToDormancy');
this.savingProductSettingsForm.removeControl('daysToEscheat');
this.savingProductSettingsForm.get('lockinPeriodFrequency').setValue('');
this.savingProductSettingsForm.get('lockinPeriodFrequencyType').setValue('');
this.savingProductSettingsForm.get('lockinPeriodFrequency').clearValidators();
this.savingProductSettingsForm.get('lockinPeriodFrequencyType').clearValidators();
}
this.savingProductSettingsForm.get('lockinPeriodFrequency').updateValueAndValidity();
this.savingProductSettingsForm.get('lockinPeriodFrequencyType').updateValueAndValidity();
});
}
get savingProductSettings() {
const formValue = { ...this.savingProductSettingsForm.value };
delete formValue.enableLockinPeriod;
return formValue;
return { ...this.savingProductSettingsForm.value };
}
}
@@ -9,10 +9,19 @@
<form [formGroup]="savingProductTermsForm">
<div class="spt-section">
<div class="spt-section__header">
<i class="fas fa-percentage"></i>
<h3>{{ 'labels.inputs.Interest Rate' | translate }}</h3>
<span class="spt-section__badge"><i class="fas fa-chart-line"></i></span>
<span>Interest Rate &amp; Posting Terms</span>
</div>
<div class="spt-grid">
<div class="spt-body">
<div class="spt-banner">
<span class="spt-banner__icon">ℹ️</span>
<span>
Savings Account base rate:
<b>{{ savingProductTermsForm.get('nominalAnnualInterestRate').value }}%</b>
</span>
</div>
<div class="spt-grid spt-grid--three">
<mat-form-field appearance="outline" class="spt-field">
<mat-label>{{ 'labels.inputs.Nominal Annual Interest' | translate }}</mat-label>
<input
@@ -31,38 +40,57 @@
</mat-error>
}
@if (savingProductTermsForm.get('nominalAnnualInterestRate').hasError('min')) {
<mat-error>
{{ 'labels.inputs.Nominal Annual Interest' | translate }} must be zero or greater
</mat-error>
<mat-error> {{ 'labels.inputs.Nominal Annual Interest' | translate }} must be zero or greater </mat-error>
}
</mat-form-field>
</div>
</div>
<div class="spt-section">
<div class="spt-section__header">
<i class="fas fa-sliders-h"></i>
<h3>{{ 'labels.inputs.Interest Compounding' | translate }}</h3>
</div>
<div class="spt-grid">
<mat-form-field appearance="outline" class="spt-field">
<mat-label>{{ 'labels.inputs.Interest Compounding Period' | translate }}</mat-label>
<mat-label>Effective Date</mat-label>
<input matInput [matDatepicker]="effectiveDatePicker" formControlName="effectiveDate" />
<mat-datepicker-toggle matSuffix [for]="effectiveDatePicker"></mat-datepicker-toggle>
<mat-datepicker #effectiveDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>{{ 'labels.inputs.Interest Calculation Type' | translate }}</mat-label>
<mat-select
formControlName="interestCompoundingPeriodType"
matTooltip="{{ 'tooltips.The period at which interest rate' | translate }}"
formControlName="interestCalculationType"
matTooltip="{{ 'tooltips.The method used to calculate interest' | translate }}"
required
>
@for (interestCompoundingPeriodType of interestCompoundingPeriodTypeData; track interestCompoundingPeriodType) {
<mat-option [value]="interestCompoundingPeriodType.id">
{{ interestCompoundingPeriodType.value | translateKey: 'catalogs' }}
@for (interestCalculationType of interestCalculationTypeOptionData; track interestCalculationType.id) {
<mat-option [value]="interestCalculationType.id">
{{ interestCalculationType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
<mat-error>
{{ 'labels.inputs.Interest Compounding Period' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.inputs.Interest Calculation Type' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
</div>
<div class="spt-grid spt-grid--three">
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Interest Calculate</mat-label>
<mat-select formControlName="interestCalculateSelector" required>
@for (mode of interestCalculationTypeData; track mode.id) {
<mat-option [value]="mode.id">{{ mode.value }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (savingProductTermsForm.value.interestCalculateSelector === 2) {
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Average Period</mat-label>
<mat-select formControlName="averagePeriod">
@for (period of periodFrequencyData; track period.id) {
<mat-option [value]="period.value">{{ period.value }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="spt-field">
<mat-label>{{ 'labels.inputs.Interest Posting Period' | translate }}</mat-label>
@@ -82,47 +110,47 @@
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
</div>
<div class="spt-footer">
<div class="spt-footer__grid">
<div class="spt-toggle-card">
<div>
<h4>Auto Interest Post</h4>
</div>
<div class="spt-toggle-card__choices">
@for (option of yesNoOptions; track option.value) {
<label class="inline-flex items-center cursor-pointer">
<input type="radio" class="w-4 h-4" formControlName="autoPost" [value]="option.value" />
<span class="ml-2 text-sm">{{ option.label }}</span>
</label>
}
</div>
</div>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>{{ 'labels.inputs.Interest Calculated using' | translate }}</mat-label>
<mat-select
formControlName="interestCalculationType"
matTooltip="{{ 'tooltips.The method used to calculate interest' | translate }}"
required
>
@for (interestCalculationType of interestCalculationTypeData; track interestCalculationType) {
<mat-option [value]="interestCalculationType.id">
{{ interestCalculationType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
<mat-error>
{{ 'labels.inputs.Interest Calculated using' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
<mat-label>Frequency (Fix Day)</mat-label>
<input type="number" matInput formControlName="frequencyFixDay" />
<mat-hint>Days</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>{{ 'labels.inputs.Days in Year' | translate }}</mat-label>
<mat-select
formControlName="interestCalculationDaysInYearType"
matTooltip="{{ 'tooltips.To calculate interest' | translate }}"
required
>
@for (
interestCalculationDaysInYearType of interestCalculationDaysInYearTypeData;
track interestCalculationDaysInYearType
) {
<mat-option [value]="interestCalculationDaysInYearType.id">
{{ interestCalculationDaysInYearType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
<mat-error>
{{ 'labels.inputs.Days in Year' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
<mat-label>Last Interest Date</mat-label>
<input matInput [matDatepicker]="lastInterestDatePicker" formControlName="lastInterestDate" />
<mat-datepicker-toggle matSuffix [for]="lastInterestDatePicker"></mat-datepicker-toggle>
<mat-datepicker #lastInterestDatePicker></mat-datepicker>
</mat-form-field>
</div>
@if (savingProductTermsForm.value.interestCalculateSelector === 1) {
<div class="spt-note">
<span>⚠️</span>
<span
>Daily product-based interest accrual enabled - interest calculated EOD on daily closing balance.</span
>
</div>
}
</div>
</div>
</div>
</form>
@@ -44,17 +44,36 @@ $gray-900: #202124;
}
}
.spt-section__badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: #eff6ff;
font-size: 14px;
}
.spt-body {
background: #fff;
}
.spt-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
@media (max-width: 600px) {
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.spt-grid--three {
grid-template-columns: repeat(3, 1fr);
}
.spt-field {
width: 100%;
@@ -62,3 +81,67 @@ $gray-900: #202124;
grid-column: 1 / -1;
}
}
.spt-banner {
display: flex;
align-items: center;
gap: 10px;
margin: 16px 24px 0;
padding: 12px 14px;
background: #eff6ff;
border: 1px solid #dbeafe;
border-radius: 8px;
color: #1d4ed8;
font-size: 13px;
&__icon {
font-size: 14px;
}
}
.spt-footer {
padding: 0 24px 24px;
border-top: 1px solid #f1f5f9;
}
.spt-footer__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding-top: 18px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
.spt-toggle-card {
padding: 12px 0;
h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
&__choices {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
}
.spt-note {
display: flex;
align-items: center;
gap: 10px;
margin-top: 18px;
padding: 12px 14px;
border: 1px solid #fde68a;
background: #fffbeb;
color: #b45309;
border-radius: 8px;
font-size: 12px;
}
@@ -10,6 +10,8 @@ import { Component, OnInit, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatTooltip } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
@@ -24,6 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
})
export class SavingProductTermsStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
@Input() savingProductsTemplate: any;
@@ -32,8 +36,22 @@ export class SavingProductTermsStepComponent implements OnInit {
interestCompoundingPeriodTypeData: any;
interestPostingPeriodTypeData: any;
interestCalculationTypeData: any;
interestCalculationTypeOptionData: any;
interestCalculationDaysInYearTypeData: any;
periodFrequencyData: any[] = [];
yesNoOptions: Array<{
value: boolean;
label: string;
}> = [
{
value: true,
label: 'Yes'
},
{
value: false,
label: 'No'
}
];
constructor() {
this.createSavingProductTermsForm();
}
@@ -42,15 +60,91 @@ export class SavingProductTermsStepComponent implements OnInit {
this.interestCompoundingPeriodTypeData = this.savingProductsTemplate.interestCompoundingPeriodTypeOptions;
this.interestPostingPeriodTypeData = this.savingProductsTemplate.interestPostingPeriodTypeOptions;
this.interestCalculationTypeData = this.savingProductsTemplate.interestCalculationTypeOptions;
this.interestCalculationTypeOptionData =
this.savingProductsTemplate.interestCalculationTypeOption ||
this.savingProductsTemplate.interestCalculationTypeOptions;
this.interestCalculationDaysInYearTypeData = this.savingProductsTemplate.interestCalculationDaysInYearTypeOptions;
this.periodFrequencyData = this.savingProductsTemplate.periodFrequencyOptions || [];
this.setYesNoOptions(this.savingProductsTemplate?.yesNoOptions);
const isEditing = !!this.savingProductsTemplate.id;
const defaultPeriod = this.periodFrequencyData[0]?.value || 'Monthly';
this.savingProductTermsForm.patchValue({
nominalAnnualInterestRate: this.savingProductsTemplate.nominalAnnualInterestRate,
interestCompoundingPeriodType: this.savingProductsTemplate.interestCompoundingPeriodType.id,
interestPostingPeriodType: this.savingProductsTemplate.interestPostingPeriodType.id,
interestCalculationType: this.savingProductsTemplate.interestCalculationType.id,
interestCalculationDaysInYearType: this.savingProductsTemplate.interestCalculationDaysInYearType.id
nominalAnnualInterestRate: isEditing
? this.savingProductsTemplate.nominalAnnualInterestRate
: this.savingProductsTemplate.nominalAnnualInterestRate,
interestCompoundingPeriodType: isEditing
? this.savingProductsTemplate.interestCompoundingPeriodType?.id
: this.savingProductsTemplate.interestCompoundingPeriodType?.id,
interestPostingPeriodType: isEditing
? this.savingProductsTemplate.interestPostingPeriodType?.id
: this.savingProductsTemplate.interestPostingPeriodType?.id,
interestCalculationType: isEditing
? this.savingProductsTemplate.interestCalculationTypeOption?.id ||
this.interestCalculationTypeOptionData?.[0]?.id ||
1
: this.interestCalculationTypeOptionData?.[0]?.id || 1,
interestCalculationDaysInYearType: isEditing
? this.savingProductsTemplate.interestCalculationDaysInYearType?.id
: this.savingProductsTemplate.interestCalculationDaysInYearType?.id,
effectiveDate: this.savingProductsTemplate.effectiveDate
? new Date(this.savingProductsTemplate.effectiveDate)
: '',
interestCalculateSelector: isEditing
? this.savingProductsTemplate.interestCalculateSelector || 1
: this.savingProductsTemplate.interestCalculationType?.id || this.interestCalculationTypeData?.[0]?.id || 1,
averagePeriod: isEditing ? this.savingProductsTemplate.averagePeriod || defaultPeriod : defaultPeriod,
autoPost: isEditing ? (this.savingProductsTemplate.autoPost ?? true) : true,
frequencyFixDay: isEditing ? this.savingProductsTemplate.frequencyFixDay || 30 : 30,
lastInterestDate: this.savingProductsTemplate.lastInterestDate
? new Date(this.savingProductsTemplate.lastInterestDate)
: ''
});
// Map code value IDs to backend enum values for interestCalculationType
// Backend expects: 1=Daily Balance, 2=Average Daily Balance
const currentInterestCalcType = this.savingProductTermsForm.get('interestCalculationType').value;
if (currentInterestCalcType && currentInterestCalcType > 2) {
// If it's a code value ID (like 340, 341), map it to backend enum
// Default to 1 (Daily Balance) for custom code values
this.savingProductTermsForm.patchValue({
interestCalculationType: 1
});
}
}
private setYesNoOptions(options: any[]) {
if (!Array.isArray(options)) {
return;
}
const mappedOptions = options
.map((option: any) => {
const rawValue = option?.name ?? option?.value ?? option;
const normalized = typeof rawValue === 'string' ? rawValue.trim().toUpperCase() : rawValue;
if (normalized === 'YES') {
return {
value: true,
label: 'Yes'
};
}
if (normalized === 'NO') {
return {
value: false,
label: 'No'
};
}
return null;
})
.filter((value: any) => !!value);
if (mappedOptions.length) {
this.yesNoOptions = mappedOptions;
}
}
createSavingProductTermsForm() {
@@ -77,11 +171,41 @@ export class SavingProductTermsStepComponent implements OnInit {
interestCalculationDaysInYearType: [
'',
Validators.required
]
],
effectiveDate: [''],
interestCalculateSelector: [1],
averagePeriod: ['Monthly'],
autoPost: [true],
frequencyFixDay: [
30,
[Validators.min(0)]
],
lastInterestDate: ['']
});
}
get savingProductTerms() {
return this.savingProductTermsForm.value;
const formValue = { ...this.savingProductTermsForm.value };
const dateFormat = this.settingsService.dateFormat;
const prevEffectiveDate: Date = this.savingProductTermsForm.value.effectiveDate;
const prevLastInterestDate: Date = this.savingProductTermsForm.value.lastInterestDate;
if (formValue.effectiveDate instanceof Date) {
formValue.effectiveDate = this.dateUtils.formatDate(prevEffectiveDate, dateFormat) || '';
}
if (formValue.lastInterestDate instanceof Date) {
formValue.lastInterestDate = this.dateUtils.formatDate(prevLastInterestDate, dateFormat) || '';
}
// Convert interestCalculationType from code value ID to backend enum value
// Backend expects: 1=Daily Balance, 2=Average Daily Balance
if (formValue.interestCalculationType && formValue.interestCalculationType > 2) {
formValue.interestCalculationType = 1; // Default to Daily Balance
}
return {
...formValue
};
}
}
@@ -24,7 +24,7 @@ export class SavingProductResolver {
private productsService = inject(ProductsService);
/**
* Returns the saving product data.
* Returns the saving product data with template options for dropdown lookups.
* @returns {Observable<any>}
*/
resolve(route: ActivatedRouteSnapshot): Observable<any> {
@@ -18,91 +18,213 @@
</button>
</div>
<div class="tab-container">
<div class="responsive-column">
<div class="responsive-column">
<h2 class="mat-h2 flex-100">{{ savingProduct.name }}</h2>
<mat-divider [inset]="true"></mat-divider>
<mat-divider class="flex-100"></mat-divider>
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Details' | translate }}</h3>
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Details' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
<div class="flex-fill layout-row">
<mat-divider class="flex-100"></mat-divider>
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Short Name' | translate }}:</span>
<span class="flex-60">{{ savingProduct.shortName }}</span>
</div>
@if (savingProduct.description) {
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Description' | translate }}:</span>
<span class="flex-60">{{ savingProduct.description }}</span>
</div>
}
<h3 class="mat-h3 flex-fill">{{ 'labels.inputs.Currency' | translate }}</h3>
@if (
savingProduct.accountType ||
savingProduct.customerSegment ||
savingProduct.productCode ||
savingProduct.effectiveDate ||
savingProduct.effectiveTo ||
savingProduct.endDate ||
savingProduct.introducerRequired !== undefined ||
savingProduct.shareLinkingEnabled !== undefined
) {
<h3 class="mat-h3 flex-100">Product Classification</h3>
<mat-divider [inset]="true"></mat-divider>
<mat-divider class="flex-100"></mat-divider>
<div class="flex-fill layout-row">
@if (savingProduct.accountType) {
<div class="flex-100 layout-row">
<span class="flex-40">Account Type:</span>
<span class="flex-60">{{ savingProduct.accountType }}</span>
</div>
}
@if (savingProduct.customerSegment) {
<div class="flex-100 layout-row">
<span class="flex-40">Customer Segment:</span>
<span class="flex-60">{{ savingProduct.customerSegment }}</span>
</div>
}
@if (savingProduct.productCode) {
<div class="flex-100 layout-row">
<span class="flex-40">Product Code:</span>
<span class="flex-60">{{ savingProduct.productCode }}</span>
</div>
}
@if (savingProduct.effectiveDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Effective Date:</span>
<span class="flex-60">{{ savingProduct.effectiveDate }}</span>
</div>
}
@if (savingProduct.effectiveTo) {
<div class="flex-100 layout-row">
<span class="flex-40">Effective To:</span>
<span class="flex-60">{{ savingProduct.effectiveTo }}</span>
</div>
}
@if (savingProduct.endDate) {
<div class="flex-100 layout-row">
<span class="flex-40">End Date:</span>
<span class="flex-60">{{ savingProduct.endDate }}</span>
</div>
}
@if (savingProduct.introducerRequired !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Introducer Required:</span>
<span class="flex-60">{{
savingProduct.introducerRequired === true || savingProduct.introducerRequired === 'Yes' ? 'Yes' : 'No'
}}</span>
</div>
}
@if (savingProduct.shareLinkingEnabled !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Share Linking Enabled:</span>
<span class="flex-60">{{ savingProduct.shareLinkingEnabled | yesNo }}</span>
</div>
}
}
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Currency' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider>
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Currency' | translate }}:</span>
<span class="flex-60">{{ savingProduct.currency.name }}</span>
</div>
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Decimal Places' | translate }}:</span>
<span class="flex-60">{{ savingProduct.currency.decimalPlaces }}</span>
</div>
<div class="flex-fill layout-row">
@if (savingProduct.currency.inMultiplesOf !== undefined && savingProduct.currency.inMultiplesOf !== null) {
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Currency in multiples of' | translate }}:</span>
<span class="flex-60">{{ savingProduct.currency.inMultiplesOf }}</span>
</div>
}
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Terms' | translate }}</h3>
@if (savingProduct.dormancyAlert !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Dormancy Alert:</span>
<span class="flex-60">{{ savingProduct.dormancyAlert | yesNo }}</span>
</div>
}
<mat-divider [inset]="true"></mat-divider>
@if (savingProduct.nominationFacility !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">Nomination Facility:</span>
<span class="flex-60">{{ savingProduct.nominationFacility | yesNo }}</span>
</div>
}
<div class="flex-fill layout-row">
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Terms' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider>
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Nominal Annual Interest' | translate }}:</span>
<span class="flex-60">{{ savingProduct.nominalAnnualInterestRate | formatNumber }} %</span>
</div>
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Interest Compounding Period' | translate }}:</span>
<span class="flex-60">{{ savingProduct.interestCompoundingPeriodType.value | translateKey: 'catalogs' }}</span>
</div>
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Interest Posting Period' | translate }}:</span>
<span class="flex-60">{{ savingProduct.interestPostingPeriodType.value | translateKey: 'catalogs' }}</span>
</div>
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Interest Calculated using' | translate }}:</span>
<span class="flex-60">{{ savingProduct.interestCalculationType.value | translateKey: 'catalogs' }}</span>
</div>
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Days in Year' | translate }}:</span>
<span class="flex-60">{{
savingProduct.interestCalculationDaysInYearType.value | translateKey: 'catalogs'
}}</span>
<span class="flex-60">{{ savingProduct.interestCalculationDaysInYearType.value | translateKey: 'catalogs' }}</span>
</div>
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Settings' | translate }}</h3>
@if (savingProduct.effectiveDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Effective Date:</span>
<span class="flex-60">{{ savingProduct.effectiveDate }}</span>
</div>
}
<mat-divider [inset]="true"></mat-divider>
<div class="flex-100 layout-row">
<span class="flex-40">Interest Calculate:</span>
<span class="flex-60">{{ savingProduct.interestCalculateSelector }}</span>
</div>
@if (savingProduct.averagePeriod) {
<div class="flex-100 layout-row">
<span class="flex-40">Average Period:</span>
<span class="flex-60">{{ savingProduct.averagePeriod }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Auto Post:</span>
<span class="flex-60">{{ savingProduct.autoPost | yesNo }}</span>
</div>
@if (savingProduct.frequencyFixDay) {
<div class="flex-100 layout-row">
<span class="flex-40">Frequency Fix Day:</span>
<span class="flex-60">{{ savingProduct.frequencyFixDay }}</span>
</div>
}
@if (savingProduct.lastInterestDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Last Interest Date:</span>
<span class="flex-60">{{ savingProduct.lastInterestDate }}</span>
</div>
}
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Settings' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider>
@if (savingProduct.minRequiredOpeningBalance) {
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Minimum Opening Balance' | translate }}:</span>
<span class="flex-60">{{ savingProduct.minRequiredOpeningBalance | formatNumber }}</span>
</div>
}
@if (savingProduct.lockinPeriodFrequency) {
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Lock-in Period' | translate }}:</span>
<span class="flex-60">{{
savingProduct.lockinPeriodFrequency + ' ' + savingProduct.lockinPeriodFrequencyType.value
@@ -110,77 +232,94 @@
</div>
}
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}:</span>
<span class="flex-60">{{ savingProduct.withdrawalFeeForTransfers | yesNo }}</span>
</div>
@if (savingProduct.balanceFrequency) {
<div class="flex-100 layout-row">
<span class="flex-40">Balance Frequency:</span>
<span class="flex-60">{{ savingProduct.balanceFrequency }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Enable Lock-in Period:</span>
<span class="flex-60">{{ savingProduct.enableLockinPeriod | yesNo }}</span>
</div>
@if (savingProduct.minBalanceForInterestCalculation) {
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}:</span>
<span class="flex-60">{{ savingProduct.minBalanceForInterestCalculation | formatNumber }}</span>
</div>
}
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Enforce Minimum Balance' | translate }}:</span>
<span class="flex-60">{{ savingProduct.enforceMinRequiredBalance | yesNo }}</span>
</div>
@if (savingProduct.minRequiredBalance) {
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Minimum Balance' | translate }}:</span>
<span class="flex-60">{{ savingProduct.minRequiredBalance | formatNumber }}</span>
</div>
}
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Withhold Tax is Applicable' | translate }}:</span>
<span class="flex-60">{{ savingProduct.withHoldTax | yesNo }}</span>
</div>
@if (savingProduct.withHoldTax) {
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Tax Group' | translate }}:</span>
<span class="flex-60">{{ savingProduct.taxGroup.name }}</span>
</div>
}
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Is Overdraft Allowed' | translate }}:</span>
<span class="flex-60">{{ savingProduct.allowOverdraft | yesNo }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Account Closed Zero Balance in Day Closing:</span>
<span class="flex-60">{{ savingProduct.accountClosedZeroBalanceInDayClosing | yesNo }}</span>
</div>
@if (savingProduct.minOverdraftForInterestCalculation) {
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40"
>{{ 'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate }}:</span
>
<span class="flex-60">{{ savingProduct.minOverdraftForInterestCalculation }}</span>
<span class="flex-60">{{ savingProduct.minOverdraftForInterestCalculation | formatNumber }}</span>
</div>
}
@if (savingProduct.nominalAnnualInterestRateOverdraft) {
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}:</span>
<span class="flex-60">{{ savingProduct.nominalAnnualInterestRateOverdraft }} %</span>
<span class="flex-60">{{ savingProduct.nominalAnnualInterestRateOverdraft | formatNumber }} %</span>
</div>
}
@if (savingProduct.overdraftLimit) {
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}:</span>
<span class="flex-60">{{ savingProduct.overdraftLimit | formatNumber }}</span>
</div>
}
<div class="flex-fill layout-row">
<div class="flex-100 layout-row">
<span class="flex-40">{{ 'labels.inputs.Enable Dormancy Tracking' | translate }}:</span>
<span class="flex-60">{{ savingProduct.isDormancyTrackingActive | yesNo }}</span>
</div>
@if (savingProduct.isDormancyTrackingActive) {
<div class="layout-row-wrap responsive-column flex-fill">
<div class="layout-row-wrap responsive-column flex-100">
<span class="flex-40">{{ 'labels.inputs.Number of Days to Inactive sub-status' | translate }}:</span>
<span class="flex-60">{{ savingProduct.daysToInactive }}</span>
<span class="flex-40">{{ 'labels.inputs.Number of Days to Dormant sub-status' | translate }}:</span>
@@ -190,11 +329,85 @@
</div>
}
<h3 class="mat-h3 flex-100">Account Facilities</h3>
<mat-divider class="flex-100"></mat-divider>
@if (savingProduct.accountFacilityConfig) {
<div class="layout-row-wrap responsive-column flex-100">
@for (feature of accountFacilityFeatureItems; track feature.key) {
@if (savingProduct.accountFacilityConfig[feature.key] !== undefined) {
<div class="flex-100 layout-row">
<span class="flex-40">{{ feature.label }}:</span>
<span class="flex-60">{{ savingProduct.accountFacilityConfig[feature.key] | yesNo }}</span>
</div>
}
}
</div>
}
@if (savingProduct.accountFacilityLimits?.length) {
<div class="layout-row-wrap responsive-column flex-100">
<h4 class="mat-h4 flex-100">Account Facility Limits</h4>
<mat-divider class="flex-100"></mat-divider>
<table class="flex-100 mat-elevation-z1" mat-table [dataSource]="savingProduct.accountFacilityLimits">
<ng-container matColumnDef="limitType">
<th mat-header-cell *matHeaderCellDef>Limit Type</th>
<td mat-cell *matCellDef="let row">{{ row.limitType }}</td>
</ng-container>
<ng-container matColumnDef="generalLimit">
<th mat-header-cell *matHeaderCellDef>General Limit</th>
<td mat-cell *matCellDef="let row">{{ row.generalLimit }}</td>
</ng-container>
<ng-container matColumnDef="minorAccountLimit">
<th mat-header-cell *matHeaderCellDef>Minor Account Limit</th>
<td mat-cell *matCellDef="let row">{{ row.minorAccountLimit }}</td>
</ng-container>
<ng-container matColumnDef="smallSavingLimit">
<th mat-header-cell *matHeaderCellDef>Small Saving Limit</th>
<td mat-cell *matCellDef="let row">{{ row.smallSavingLimit }}</td>
</ng-container>
<tr
mat-header-row
*matHeaderRowDef="['limitType', 'generalLimit', 'minorAccountLimit', 'smallSavingLimit']"
></tr>
<tr
mat-row
*matRowDef="let row; columns: ['limitType', 'generalLimit', 'minorAccountLimit', 'smallSavingLimit']"
></tr>
</table>
</div>
}
@if (savingProduct.depositLimitKyc) {
<div class="layout-row-wrap responsive-column flex-100">
<h4 class="mat-h4 flex-100">Deposit Limit (KYC)</h4>
<div class="flex-100 layout-row">
<span class="flex-40">General:</span>
<span class="flex-60"
>{{ savingProduct.depositLimitKyc.general.kycType }} - {{ savingProduct.depositLimitKyc.general.limit }}</span
>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Minor:</span>
<span class="flex-60"
>{{ savingProduct.depositLimitKyc.minor.kycType }} - {{ savingProduct.depositLimitKyc.minor.limit }}</span
>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Small Saving:</span>
<span class="flex-60"
>{{ savingProduct.depositLimitKyc.smallSaving.kycType }} -
{{ savingProduct.depositLimitKyc.smallSaving.limit }}</span
>
</div>
</div>
}
@if (savingProduct.charges.length) {
<div class="layout-row-wrap responsive-column flex-fill">
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Charges' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
<table class="flex-fill mat-elevation-z1" mat-table [dataSource]="savingProduct.charges">
<div class="layout-row-wrap responsive-column flex-100">
<h3 class="mat-h3 flex-100">{{ 'labels.heading.Charges' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider>
<table class="flex-100 mat-elevation-z1" mat-table [dataSource]="savingProduct.charges">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Name' | translate }}</th>
<td mat-cell *matCellDef="let charge">
@@ -225,9 +438,9 @@
</div>
}
<h3 class="mat-h3 flex-fill">{{ 'labels.heading.Accounting' | translate }}</h3>
<h3 class="mat-h3 flex-100">{{ 'labels.heading.Accounting' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
<mat-divider class="flex-100"></mat-divider>
<mifosx-view-savings-accounting-details
class="flex-100"
@@ -241,14 +454,14 @@
savingProduct.feeToIncomeAccountMappings?.length ||
savingProduct.penaltyToIncomeAccountMappings?.length
) {
<div class="flex-fill layout-row-wrap responsive-column">
<h3 class="mat-h3 flex-fill">{{ 'labels.inputs.Advanced Accounting Rules' | translate }}</h3>
<mat-divider [inset]="true"></mat-divider>
<div class="flex-100 layout-row-wrap responsive-column">
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Advanced Accounting Rules' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider>
@if (savingProduct.paymentChannelToFundSourceMappings?.length) {
<div class="flex-fill layout-row-wrap responsive-column">
<h4 class="mat-h4 flex-fill">{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}</h4>
<div class="flex-100 layout-row-wrap responsive-column">
<h4 class="mat-h4 flex-100">{{ 'labels.inputs.Fund Sources for Payment Channels' | translate }}</h4>
<table
class="mat-elevation-z1 flex-fill"
class="mat-elevation-z1 flex-100"
mat-table
[dataSource]="savingProduct.paymentChannelToFundSourceMappings"
>
@@ -270,20 +483,18 @@
</div>
}
@if (savingProduct.feeToIncomeAccountMappings?.length) {
<div class="flex-fill layout-row-wrap responsive-column">
<h4 class="mat-h4 flex-fill">{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}</h4>
<table class="flex-fill mat-elevation-z1" mat-table [dataSource]="savingProduct.feeToIncomeAccountMappings">
<div class="flex-100 layout-row-wrap responsive-column">
<h4 class="mat-h4 flex-100">{{ 'labels.inputs.Fees to Specific Income Accounts' | translate }}</h4>
<table class="flex-100 mat-elevation-z1" mat-table [dataSource]="savingProduct.feeToIncomeAccountMappings">
<ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fees' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome">
{{ feesIncome.charge.name }}
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fee/Charge' | translate }}</th>
<td mat-cell *matCellDef="let fee">
{{ fee.charge.name }}
</td>
</ng-container>
<ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome">
({{ feesIncome.incomeAccount.glCode }}) {{ feesIncome.incomeAccount.name }}
</td>
<td mat-cell *matCellDef="let fee">({{ fee.incomeAccount.glCode }}) {{ fee.incomeAccount.name }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
@@ -291,23 +502,23 @@
</div>
}
@if (savingProduct.penaltyToIncomeAccountMappings?.length) {
<div class="flex-fill layout-row-wrap responsive-column">
<h4 class="mat-h4 flex-fill">{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}</h4>
<div class="flex-100 layout-row-wrap responsive-column">
<h4 class="mat-h4 flex-100">{{ 'labels.inputs.Penalties to Specific Income Accounts' | translate }}</h4>
<table
class="mat-elevation-z1 flex-fill"
class="flex-100 mat-elevation-z1"
mat-table
[dataSource]="savingProduct.penaltyToIncomeAccountMappings"
>
<ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Penalty' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome">
{{ penaltyIncome.charge.name }}
<td mat-cell *matCellDef="let penalty">
{{ penalty.charge.name }}
</td>
</ng-container>
<ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome">
({{ penaltyIncome.incomeAccount.glCode }}) {{ penaltyIncome.incomeAccount.name }}
<td mat-cell *matCellDef="let penalty">
({{ penalty.incomeAccount.glCode }}) {{ penalty.incomeAccount.name }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
@@ -317,5 +528,4 @@
}
</div>
}
</div>
</div>
@@ -71,3 +71,95 @@ mat-divider {
.margin-t {
margin-top: 1em;
}
.spa-gl-summary,
.spa-other-gl-summary {
display: flex;
flex-direction: column;
gap: 0.9rem;
margin-top: 1.25rem;
}
.spa-gl-summary__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0 0.5rem;
}
.spa-gl-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.15rem;
border: 1px solid #e3e7f2;
border-radius: 12px;
background: #fff;
}
.spa-gl-section h5 {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #91a0bc;
}
.spa-gl-row {
display: grid;
grid-template-columns: minmax(220px, 40%) 1fr;
gap: 1rem;
align-items: baseline;
padding: 0.2rem 0;
}
.spa-gl-row__label {
color: #1f2f4a;
font-weight: 500;
}
.spa-gl-row__value {
color: #101828;
font-weight: 600;
word-break: break-word;
}
.spa-other-gl-table {
margin-top: 0.5rem;
margin-bottom: 0.75rem;
table-layout: fixed;
width: 100%;
}
.spa-other-gl-table:first-of-type {
margin-top: 0.25rem;
}
.spa-other-gl-summary .mat-elevation-z1 {
border-radius: 10px;
overflow: hidden;
}
.spa-other-gl-table th.mat-mdc-header-cell,
.spa-other-gl-table td.mat-mdc-cell {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.spa-other-gl-table th.mat-mdc-header-cell:first-child,
.spa-other-gl-table td.mat-mdc-cell:first-child {
width: 38%;
}
.spa-other-gl-table th.mat-mdc-header-cell:last-child,
.spa-other-gl-table td.mat-mdc-cell:last-child {
width: 62%;
word-break: break-word;
}
@media (width <= 768px) {
.spa-gl-row {
grid-template-columns: 1fr;
gap: 0.35rem;
}
}
@@ -72,6 +72,25 @@ export class SavingProductGeneralTabComponent {
'incomeAccountId'
];
accountFacilityFeatureItems = [
{ key: 'atmFacility', label: 'ATM Facility' },
{ key: 'atmWithdrawalFacility', label: 'ATM Withdrawal Facility' },
{ key: 'upiFacility', label: 'UPI Facility' },
{ key: 'posTransactionsFacility', label: 'POS Transactions Facility' },
{ key: 'chequeBook', label: 'Cheque Book' },
{ key: 'impsFacility', label: 'IMPS Facility' },
{ key: 'smsAlerts', label: 'SMS Alerts' },
{ key: 'bbpsFacility', label: 'BBPS Facility' },
{ key: 'insuranceCover', label: 'Insurance Cover' },
{ key: 'bepgFacility', label: 'BEPG Facility' },
{ key: 'neftFacility', label: 'NEFT Facility' },
{ key: 'rtgsFacility', label: 'RTGS Facility' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
];
constructor() {
this.route.data.subscribe((data: { savingProduct: any }) => {
this.savingProduct = data.savingProduct;
@@ -85,4 +104,22 @@ export class SavingProductGeneralTabComponent {
isAccrualAccounting(): boolean {
return this.accounting.isAccrualAccounting(this.savingProduct.accountingRule);
}
formatGlAccountById(glAccountId: number, options: any[]): string {
if (!glAccountId || !Array.isArray(options)) {
return '-';
}
const account = this.accounting.glAccountLookUp(glAccountId, options as any);
return account ? `(${account.glCode}) ${account.name}` : '-';
}
formatGlAccountByCode(glCode: string, options: any[]): string {
if (!glCode || !Array.isArray(options)) {
return '-';
}
const account = options.find((option) => option?.glCode === glCode);
return account ? `(${account.glCode}) ${account.name}` : glCode;
}
}
+2 -3
View File
@@ -5,13 +5,12 @@
* 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/.
*/
// IMPORTANT: THIS FILE IS AUTO GENERATED! DO NOT MANUALLY EDIT OR CHECKIN!
/* tslint:disable */
export default {
'mifos_x': {
'version': '260407',
'hash': 'gb890d24bb'
'version': '260413',
'hash': '79e455c4'
},
'allow_switching_backend_instance': true
};