feat(dds): DDS product creation in tabs with an Accounting tab

- DDS Product Creation split into Details / Interest & Maturity /
  Premature Closure / Accounting tabs, like the savings product
- Accounting tab picks Customer Deposits (liability), Interest Expense
  and Agent Commission Expense (expense) GLs; product view shows them
- Approval review shows the product's GLs, old -> new on edits
- Agent master no longer reads the removed dds-deposit-product setting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 12:43:51 +05:30
co-authored by Claude Opus 5.5
parent 293b671966
commit 53bb97d6ed
9 changed files with 885 additions and 218 deletions
@@ -128,6 +128,46 @@
<span class="ddr-chip ddr-chip--edit">{{ group.changed }} changed</span>
}
</header>
@if (group.name === 'Accounting') {
<!--
The ledger accounts the product posts through, laid out as the product's own Accounting
tab lays them out: the kind of account, what it is for, and the account by name and code.
-->
<div class="ddr-acct">
@for (field of group.fields; track field.key) {
<div class="ddr-acct__row" [class.ddr-acct__row--changed]="field.changed">
<div class="ddr-acct__meta">
<span class="ddr-acct__kind" [ngClass]="'ddr-acct__kind--' + glKind(field.key).toLowerCase()">{{
glKind(field.key)
}}</span>
<span class="ddr-acct__label">
{{ field.label }}
@if (field.changed) {
<span class="ddr-tile__flag">Changed</span>
}
</span>
</div>
<div class="ddr-acct__value">
@if (field.changed) {
<span class="ddr-acct__gl ddr-acct__gl--old">
{{ glName(field.current) }}
@if (glCode(field.current)) {
<span class="ddr-acct__code">{{ glCode(field.current) }}</span>
}
</span>
<mat-icon class="ddr-acct__arrow">arrow_forward</mat-icon>
}
<span class="ddr-acct__gl" [class.ddr-acct__gl--none]="!field.value">
{{ glName(field.value) }}
@if (glCode(field.value)) {
<span class="ddr-acct__code">{{ glCode(field.value) }}</span>
}
</span>
</div>
</div>
}
</div>
} @else {
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile" [class.ddr-tile--changed]="field.changed">
@@ -149,6 +189,7 @@
</div>
}
</div>
}
</section>
}
@@ -451,3 +451,113 @@ $shadow-sm:
}
}
}
// ── A DDS product's Accounting, as its own Accounting tab shows it ───────────────────────────────────
.ddr-acct {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 20px 20px;
&__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid $border;
border-radius: 10px;
background: $bg-white;
&--changed {
border-color: $warning-border;
background: $warning-bg;
}
@media (width <= 720px) {
flex-direction: column;
align-items: flex-start;
}
}
&__meta {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
&__kind {
flex: none;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
&--liability {
background: #dbeafe;
color: #1e40af;
}
&--expense {
background: #fef3c7;
color: #92400e;
}
}
&__label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: $text-dark;
}
&__value {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
&__gl {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: $brand;
&--old {
font-weight: 500;
color: $text-soft;
text-decoration: line-through;
}
&--none {
color: #b91c1c;
}
}
&__code {
padding: 1px 8px;
border: 1px solid $border;
border-radius: 6px;
background: $bg-soft;
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 12px;
font-weight: 600;
color: $text-mid;
}
&__arrow {
width: 18px;
height: 18px;
font-size: 18px;
color: $warning;
}
}
@@ -123,6 +123,26 @@ export class DdsApprovalReviewComponent implements OnInit {
return this.fields.find((field: any) => field.key === key)?.value || '';
}
/** The kind of ledger account a product's Accounting row names - customers' deposits are owed, the rest are costs. */
glKind(key: string): string {
return key === 'depositLiabilityGlId' ? 'Liability' : 'Expense';
}
/** "DDS CUSTOMER DEPOSITS (200601)" → "DDS CUSTOMER DEPOSITS"; a missing account reads "Not set". */
glName(value: string | null | undefined): string {
if (!value) {
return 'Not set';
}
const match = /^(.*) (([^()]+))$/.exec(value);
return match ? match[1] : value;
}
/** "DDS CUSTOMER DEPOSITS (200601)" → "200601". */
glCode(value: string | null | undefined): string {
const match = value ? /^(.*) (([^()]+))$/.exec(value) : null;
return match ? match[2] : '';
}
get changedCount(): number {
return this.fields.filter((field: any) => field.changed).length;
}
@@ -131,6 +151,7 @@ export class DdsApprovalReviewComponent implements OnInit {
Product: 'inventory_2',
Interest: 'trending_up',
'Maturity & Premature Closure': 'event_busy',
Accounting: 'menu_book',
'Customer & Scheme': 'groups',
'Personal & Contact': 'contact_page',
'Minor / Guardian': 'escalator_warning',
@@ -25,7 +25,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { SystemService } from 'app/system/system.service';
import { ClientsService } from 'app/clients/clients.service';
import { SavingsService } from 'app/savings/savings.service';
import { SettingsService } from 'app/settings/settings.service';
@@ -90,7 +89,6 @@ export class CreateAgentComponent implements OnInit {
private clientsService = inject(ClientsService);
private savingsService = inject(SavingsService);
private accountingService = inject(AccountingService);
private systemService = inject(SystemService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private alertService = inject(AlertService);
@@ -286,17 +284,6 @@ export class CreateAgentComponent implements OnInit {
);
}
/**
* The savings product a DDS customer's own scheme account is opened under, read from the setting
* that names it rather than guessed from a product name a bank is free to change.
*
* Held so those accounts can be kept out of the agent's own list. They are savings accounts and
* pass every other test, but they belong to a customer's scheme: commission paid into one would be
* counted as that customer's deposits by every interest run afterwards. The server refuses it
* either way - this is so the choice is never offered rather than taken and then thrown back.
*/
private ddsProductId: number | null = null;
/**
* The accounts on offer once a type has been chosen. Type is asked first, as the legacy screen
* did, so the officer narrows to a kind of account and then picks one of that kind.
@@ -420,19 +407,6 @@ export class CreateAgentComponent implements OnInit {
error: () => (this.offices = [])
});
this.systemService.getConfigurationByName('dds-deposit-product').subscribe({
next: (config: any) => {
this.ddsProductId = config?.enabled && config?.value ? Number(config.value) : null;
// The client may already have been keyed while this was in flight, so the list is rebuilt
// rather than left showing an account this now knows to exclude.
const clientId = this.agentForm.get('clientId')?.value;
if (clientId) {
this.loadClientSavingsAccounts(String(clientId));
}
},
error: () => (this.ddsProductId = null)
});
this.accountingService.getChartOfAccounts().subscribe({
next: (accounts: any) => {
const rows = Array.isArray(accounts) ? accounts : accounts?.pageItems || [];
@@ -751,11 +725,8 @@ export class CreateAgentComponent implements OnInit {
// Savings only. Commission is credited whenever a run posts, and a fixed deposit is a
// single sum placed for a term while a recurring one takes only its own instalments -
// neither accepts the credit, so the posting would fail rather than the choice.
// A DDS scheme account is a savings account too and so passes the test above. It is
// dropped by the product the setting names - not by a product called "DDS", which is a
// name the bank may change - so the list offers only accounts the agent can be paid in.
// A DDS customer's scheme account would pass this too; the server refuses one on save.
account.depositType?.id === CreateAgentComponent.SAVINGS_DEPOSIT_TYPE &&
(this.ddsProductId === null || account.productId !== this.ddsProductId) &&
(account.status?.active || account.status?.submittedAndPendingApproval || account.status?.approved)
);
this.savingsAccountTypes = [
@@ -7,7 +7,6 @@
-->
<div class="dat-root">
<div class="dat-card">
<div class="dat-head">
<div class="dat-head__ic"><fa-icon icon="calendar-day"></fa-icon></div>
<div>
@@ -16,7 +15,27 @@
</div>
</div>
<!--
One form, split into tabs the way the savings product screen is. Every tab stays in the DOM
(preserveContent), so a field filled on one tab is still there when the user comes back to it.
-->
<form [formGroup]="accountTypeForm">
<mat-tab-group
class="dat-tabs"
animationDuration="200ms"
[preserveContent]="true"
[(selectedIndex)]="selectedTabIndex"
>
<!-- ── Details ─────────────────────────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-info-circle dat-tab-icon"></i>
Details
@if (tabHasError(detailsControls)) {
<span class="dat-tab-dot" title="Something on this tab still needs filling in"></span>
}
</ng-template>
<div class="dat-tab-content">
<div class="dat-grid">
<mat-form-field appearance="outline" class="dat-field">
<mat-label>Product Group</mat-label>
@@ -29,14 +48,34 @@
<mat-form-field appearance="outline" class="dat-field">
<mat-label>Product Code</mat-label>
<input matInput required maxlength="20" formControlName="accountTypeCode" style="text-transform: uppercase" />
<input
matInput
required
maxlength="20"
formControlName="accountTypeCode"
style="text-transform: uppercase"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="dat-field dat-field--wide">
<mat-label>Product Description</mat-label>
<input matInput required maxlength="200" formControlName="accountTypeDescription" />
</mat-form-field>
</div>
</div>
</mat-tab>
<!-- ── Interest & Maturity ─────────────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-percent dat-tab-icon"></i>
Interest &amp; Maturity
@if (tabHasError(interestControls)) {
<span class="dat-tab-dot" title="Something on this tab still needs filling in"></span>
}
</ng-template>
<div class="dat-tab-content">
<div class="dat-grid">
<mat-form-field appearance="outline" class="dat-field">
<mat-label>Interest Rate (%)</mat-label>
<input matInput required type="number" min="0" max="100" step="0.01" formControlName="interestRate" />
@@ -76,19 +115,32 @@
<mat-option [value]="true">Yes</mat-option>
<mat-option [value]="false">No</mat-option>
</mat-select>
<mat-hint>Closes the account on its maturity date, if it names a savings account to be paid into</mat-hint>
<mat-hint
>Closes the account on its maturity date, if it names a savings account to be paid into</mat-hint
>
</mat-form-field>
</div>
</div>
</mat-tab>
<!-- ── Premature Closure ───────────────────────────────────────────────────────────────── -->
<!--
The terms for closing an account on this scheme early. Set once here rather than asked of the
clerk at closing time: they are the bank's terms and the same for every account on the scheme,
and a figure typed at the counter is a figure that can be typed differently next time.
-->
<h3 class="dat-section">Premature Closure</h3>
<p class="dat-section-note">
Applied when an account on this scheme is closed before its term ends. The branch chooses at the time whether
the customer or the agent bears the cost; these set what that costs.
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-door-open dat-tab-icon"></i>
Premature Closure
@if (tabHasError(prematureControls)) {
<span class="dat-tab-dot" title="Something on this tab still needs filling in"></span>
}
</ng-template>
<div class="dat-tab-content">
<p class="dat-note">
Applied when an account on this scheme is closed before its term ends. The branch chooses at the time
whether the customer or the agent bears the cost; these set what that costs.
</p>
<div class="dat-grid">
@@ -126,14 +178,91 @@
<mat-hint>Charged whoever bears the interest — the round was not kept going.</mat-hint>
</mat-form-field>
</div>
</div>
</mat-tab>
<!-- ── Accounting ──────────────────────────────────────────────────────────────────────── -->
<!--
The product's own ledger accounts, laid out the way the savings product's Accounting tab is:
grouped by kind, each row with the GL search picker. Only accounts of the right kind are offered.
-->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-book dat-tab-icon"></i>
Accounting
@if (tabHasError(accountingControls)) {
<span class="dat-tab-dot" title="Something on this tab still needs filling in"></span>
}
</ng-template>
<div class="dat-tab-content">
<div class="spa-section">
<div class="spa-section__header">
<span class="spa-section__icon"><i class="fas fa-book"></i></span>
<h3>Accounting</h3>
</div>
<div class="spa-section__body">
@if (glLoadFailed) {
<div class="dat-gl-error">
<i class="fas fa-exclamation-triangle"></i>
The chart of accounts could not be loaded, so no ledger account can be picked. Reload the page to try
again.
</div>
}
@for (section of accountingSections; track section.key) {
<div class="spa-group">
<h4>{{ section.title }}</h4>
<div class="spa-list">
@for (row of section.rows; track row.controlName) {
<div class="spa-row">
<div class="spa-row__meta">
<span class="spa-pill" [ngClass]="'spa-pill--' + row.pill">{{ row.pillLabel }}</span>
<div class="spa-row__text">
<span class="spa-row__label">
{{ row.label }}
@if (row.required) {
<span class="spa-req">*</span>
}
</span>
<span class="spa-row__hint">{{ row.hint }}</span>
</div>
</div>
<mifosx-gl-account-selector
class="spa-row__input"
[inputFormControl]="accountTypeForm.get(row.controlName)"
[glAccountList]="row.options"
[required]="row.required"
[inputLabel]="row.inputLabel"
></mifosx-gl-account-selector>
</div>
}
</div>
</div>
}
</div>
</div>
</div>
</mat-tab>
</mat-tab-group>
<div class="dat-actions">
<button mat-raised-button type="button" (click)="cancel()">Cancel</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<span class="dat-actions__spacer"></span>
<button mat-stroked-button type="button" [disabled]="selectedTabIndex === 0" (click)="previousTab()">
<i class="fas fa-arrow-left"></i>
Previous
</button>
@if (selectedTabIndex < lastTabIndex) {
<button mat-stroked-button color="primary" type="button" (click)="nextTab()">
Next
<i class="fas fa-arrow-right"></i>
</button>
}
<button mat-raised-button color="primary" type="button" [disabled]="accountTypeForm.invalid" (click)="submit()">
<fa-icon icon="check" style="margin-right: 6px"></fa-icon>
{{ isEdit ? 'Update' : 'Submit' }}
</button>
</div>
</form>
</div>
</div>
@@ -6,57 +6,104 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.dat-root {
padding: 20px 24px 40px;
}
// Laid out like the savings product screen - a header, then the form in tabs - in the DDS screens'
// own teal.
$teal: #0f766e;
$teal-dark: #134e4a;
$teal-soft: #e6f4ee;
$border: #e2e8f0;
$text: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
.dat-card {
background: #fff;
border: 1px solid rgb(0 0 0 / 8%);
border-radius: 10px;
box-shadow: 0 1px 3px rgb(0 0 0 / 6%);
overflow: hidden;
max-width: 880px;
.dat-root {
max-width: 960px;
margin: 0 auto;
padding: 24px 16px 40px;
}
.dat-head {
display: flex;
align-items: center;
gap: 14px;
padding: 20px 24px;
border-bottom: 1px solid rgb(0 0 0 / 7%);
margin-bottom: 18px;
h2 {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
color: $text;
}
p {
margin: 2px 0 0;
font-size: 0.83rem;
color: rgb(0 0 0 / 55%);
color: $text-mid;
}
}
.dat-head__ic {
&__ic {
display: grid;
place-items: center;
flex: none;
width: 46px;
height: 46px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 1.05rem;
color: #fff;
background: linear-gradient(135deg, #0f766e, #134e4a);
flex: none;
background: linear-gradient(135deg, $teal, $teal-dark);
}
}
// ── Tabs ─────────────────────────────────────────────────────────────────────────────────────────────
.dat-tabs {
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
::ng-deep {
.mat-mdc-tab-header {
border-bottom: 1px solid rgb(0 0 0 / 8%);
}
.mat-mdc-tab {
min-width: 130px;
padding: 0 16px;
}
.mat-mdc-tab.mdc-tab--active .mdc-tab__text-label {
color: $teal;
font-weight: 600;
}
.mdc-tab-indicator__content--underline {
border-color: $teal !important;
}
}
}
.dat-tab-icon {
margin-right: 8px;
font-size: 13px;
}
// A tab with something still to fill in.
.dat-tab-dot {
display: inline-block;
width: 7px;
height: 7px;
margin-left: 6px;
border-radius: 50%;
background: #dc2626;
}
.dat-tab-content {
padding: 24px 20px;
}
.dat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 8px 20px;
padding: 26px 24px 8px;
}
.dat-field {
@@ -67,42 +114,196 @@
grid-column: 1 / -1;
}
/*
* Divides the scheme's own settings from the terms it charges for closing early. A rule rather than a
* second card, because it is the same record being filled in - the break is to stop three money
* figures reading as more of the posting settings above them.
*/
.dat-section {
margin: 20px 0 0;
padding: 18px 24px 0;
border-top: 1px solid rgb(0 0 0 / 7%);
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #0f766e;
}
.dat-section-note {
margin: 6px 0 0;
padding: 0 24px;
max-width: 68ch; /* stylelint-disable-line unit-allowed-list */
.dat-note {
margin: 0 0 18px;
max-width: 72ch; /* stylelint-disable-line unit-allowed-list */
font-size: 0.8rem;
line-height: 1.5;
color: rgb(0 0 0 / 60%);
color: $text-mid;
}
/* The note already carries the spacing, so the grid under it does not add its own again. */
.dat-section-note + .dat-grid {
padding-top: 14px;
.dat-gl-error {
margin: 0 0 16px;
padding: 10px 12px;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fef2f2;
font-size: 0.82rem;
color: #b91c1c;
i {
margin-right: 6px;
}
}
// ── Accounting tab, as the savings product's ──────────────────────────────────────────────────────────
.spa-section {
overflow: hidden;
border: 1px solid $border;
border-radius: 10px;
background: #fff;
&__header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
border-bottom: 1px solid #e8eaed;
background: #f8f9fa;
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #202124;
}
}
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: $teal-soft;
color: $teal;
font-size: 13px;
}
&__body {
padding: 16px 20px 20px;
}
}
.spa-group {
& + & {
margin-top: 20px;
}
h4 {
margin: 0 0 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: $text-soft;
}
}
.spa-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.spa-row {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 16px;
padding: 10px 12px;
border: 1px solid $border;
border-radius: 8px;
background: #fff;
@media (width <= 900px) {
flex-direction: column;
}
&__meta {
display: flex;
align-items: center;
gap: 10px;
flex: 1 1 auto;
min-width: 0;
}
&__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
&__label {
font-size: 14px;
font-weight: 600;
color: #334155;
}
&__hint {
font-size: 12px;
line-height: 1.4;
color: $text-mid;
}
&__input {
display: block;
flex: 0 0 22rem;
min-width: 18rem;
max-width: 22rem;
@media (width <= 900px) {
width: 100%;
min-width: 0;
max-width: 100%;
}
::ng-deep {
.layout-column {
width: 100%;
margin-right: 0 !important;
}
mat-form-field {
width: 100%;
}
}
}
}
.spa-req {
color: #dc2626;
}
.spa-pill {
flex: none;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
&--blue {
background: #dbeafe;
color: #1e40af;
}
&--amber {
background: #fef3c7;
color: #92400e;
}
}
// ── Footer ───────────────────────────────────────────────────────────────────────────────────────────
.dat-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid rgb(0 0 0 / 7%);
align-items: center;
gap: 10px;
margin-top: 16px;
padding: 14px 0;
border-top: 1px solid rgb(0 0 0 / 8%);
&__spacer {
flex: 1;
}
i {
margin: 0 4px;
font-size: 12px;
}
}
@media (width <= 720px) {
@@ -11,19 +11,40 @@ import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTabsModule } from '@angular/material/tabs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { GlAccountSelectorComponent } from 'app/shared/accounting/gl-account-selector/gl-account-selector.component';
import { GLAccount } from 'app/shared/models/general.model';
/** Custom Services */
import { ProductsService } from 'app/products/products.service';
import { AlertService } from 'app/core/alert/alert.service';
import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
/** acc_gl_account classification: 2 liability, 5 expense. */
const GL_LIABILITY = 2;
const GL_EXPENSE = 5;
/** One ledger account the product names, as a row on the Accounting tab. */
interface AccountingRow {
controlName: string;
label: string;
inputLabel: string;
hint: string;
pill: 'blue' | 'amber';
pillLabel: string;
required: boolean;
options: GLAccount[];
}
/**
* Account Type Creation — the legacy DDS screen this replaces, field for field: group, code,
* description, and whether interest posts automatically and how often. Nothing else: DDS never used
* the deposit-product engine, so there are no terms, charges or accounting tabs behind this.
* description, and whether interest posts automatically and how often - laid out in tabs the way
* the savings product screen is, with an Accounting tab for the ledger accounts the product posts
* through.
*
* The same form handles create and edit; an `accountTypeId` in the route switches it to edit.
*/
@@ -33,7 +54,9 @@ import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-appro
styleUrls: ['./create-dds-account-type.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
FaIconComponent,
MatTabsModule,
GlAccountSelectorComponent
]
})
export class CreateDdsAccountTypeComponent implements OnInit {
@@ -44,6 +67,38 @@ export class CreateDdsAccountTypeComponent implements OnInit {
private alertService = inject(AlertService);
private organizationService = inject(OrganizationService);
private settingsService = inject(SettingsService);
private accountingService = inject(AccountingService);
/** The tab on show; Previous and Next move it. */
selectedTabIndex = 0;
readonly lastTabIndex = 3;
/** Which controls sit on which tab - a tab whose controls are invalid shows a dot on its label. */
readonly detailsControls = [
'accountTypeGroup',
'accountTypeCode',
'accountTypeDescription'
];
readonly interestControls = [
'interestRate',
'autoInterestPost',
'autoInterestPostWhen',
'maturityAutoClose'
];
readonly prematureControls = [
'prematureLockinMonths',
'prematureInterestReduction',
'prematureAgentCommissionPenalty'
];
readonly accountingControls = [
'depositLiabilityGlId',
'interestExpenseGlId',
'commissionExpenseGlId'
];
/** The Accounting tab's rows, grouped by kind as the savings product's are. */
accountingSections: { key: string; title: string; rows: AccountingRow[] }[] = [];
glLoadFailed = false;
accountTypeGroups = ['DDS'];
@@ -124,6 +179,23 @@ export class CreateDdsAccountTypeComponent implements OnInit {
Validators.min(0),
Validators.max(100)
]
],
/*
* The product's ledger accounts (Accounting tab) - the only accounts DDS posts through for this
* product; nothing is taken from Global Configurations. All three are required, as a savings
* product's control and interest accounts are.
*/
depositLiabilityGlId: [
null,
Validators.required
],
interestExpenseGlId: [
null,
Validators.required
],
commissionExpenseGlId: [
null,
Validators.required
]
});
@@ -132,6 +204,7 @@ export class CreateDdsAccountTypeComponent implements OnInit {
}
ngOnInit(): void {
this.loadLedgerAccounts();
const routeId = this.route.snapshot.paramMap.get('accountTypeId');
if (!routeId) {
return;
@@ -150,17 +223,104 @@ export class CreateDdsAccountTypeComponent implements OnInit {
// that charges nothing - and must not be replaced by the default.
prematureLockinMonths: accountType.prematureLockinMonths ?? 12,
prematureInterestReduction: accountType.prematureInterestReduction ?? 1,
prematureAgentCommissionPenalty: accountType.prematureAgentCommissionPenalty ?? 1
prematureAgentCommissionPenalty: accountType.prematureAgentCommissionPenalty ?? 1,
depositLiabilityGlId: accountType.depositLiabilityGlId ?? null,
interestExpenseGlId: accountType.interestExpenseGlId ?? null,
commissionExpenseGlId: accountType.commissionExpenseGlId ?? null
});
});
}
/** True once a control on the tab is invalid and the user has been there or tried to submit. */
tabHasError(controls: string[]): boolean {
return controls.some((name) => {
const control = this.accountTypeForm.get(name);
return !!control && control.invalid && (control.touched || control.dirty);
});
}
previousTab(): void {
this.selectedTabIndex = Math.max(0, this.selectedTabIndex - 1);
}
nextTab(): void {
this.selectedTabIndex = Math.min(this.lastTabIndex, this.selectedTabIndex + 1);
}
/**
* Detail accounts that can take a posting, split by kind so each row offers only the kind its job
* needs - the backend refuses any other kind, and this keeps the wrong one from being picked at all.
*/
private loadLedgerAccounts(): void {
this.accountingService.getChartOfAccounts().subscribe({
next: (accounts: any) => {
const rows: GLAccount[] = (Array.isArray(accounts) ? accounts : accounts?.pageItems || []).filter(
(a: any) => a.usage?.id === 1 && !a.disabled
);
const ofType = (...types: number[]) => rows.filter((a: any) => types.includes(a.type?.id));
this.buildAccountingSections(ofType(GL_LIABILITY), ofType(GL_EXPENSE));
},
error: () => {
this.glLoadFailed = true;
this.buildAccountingSections([], []);
}
});
}
private buildAccountingSections(liabilities: GLAccount[], expenses: GLAccount[]): void {
this.accountingSections = [
{
key: 'liabilities',
title: 'Liabilities',
rows: [
{
controlName: 'depositLiabilityGlId',
label: 'DDS Customer Deposits',
inputLabel: 'Customer deposits account',
hint: 'What the bank owes the customers on this product - credited on collection, debited on payout.',
pill: 'blue',
pillLabel: 'Liability',
required: true,
options: liabilities
}
]
},
{
key: 'expenses',
title: 'Expenses',
rows: [
{
controlName: 'interestExpenseGlId',
label: 'Interest Expense',
inputLabel: 'Interest expense account',
hint: 'What interest paid to the customers on this product costs the bank.',
pill: 'amber',
pillLabel: 'Expense',
required: true,
options: expenses
},
{
controlName: 'commissionExpenseGlId',
label: 'Agent Commission Expense',
inputLabel: 'Commission expense account',
hint: "Agents' commission on collecting for this product. An agent collecting for several products is shared out by what was collected for each.",
pill: 'amber',
pillLabel: 'Expense',
required: true,
options: expenses
}
]
}
];
}
cancel(): void {
this.backToList();
}
submit(): void {
if (this.accountTypeForm.invalid) {
this.accountTypeForm.markAllAsTouched();
return;
}
const raw = this.accountTypeForm.getRawValue();
@@ -160,5 +160,34 @@
</div>
</div>
</section>
<!-- ACCOUNTING: the only ledger accounts DDS posts through for this product -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--blue"><fa-icon icon="book"></fa-icon></div>
<h3>Accounting</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">DDS Customer Deposits (Liability)</span>
<span class="vsp-v">{{
glLabel(accountType.depositLiabilityGlCode, accountType.depositLiabilityGlName)
}}</span>
<span class="vdt-note">Credited on collection, debited on payout</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Interest Expense</span>
<span class="vsp-v">{{ glLabel(accountType.interestExpenseGlCode, accountType.interestExpenseGlName) }}</span>
<span class="vdt-note">Debited when interest is posted to customers</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Agent Commission Expense</span>
<span class="vsp-v">{{
glLabel(accountType.commissionExpenseGlCode, accountType.commissionExpenseGlName)
}}</span>
<span class="vdt-note">Debited with this product's share of agents' commission</span>
</div>
</div>
</section>
}
</div>
@@ -77,6 +77,11 @@ export class ViewDdsAccountTypeComponent implements OnInit {
return `${Number(value)}%`;
}
/** A ledger account as "NAME (code)", or a plain "Not set" when the product names none. */
glLabel(code: string | null | undefined, name: string | null | undefined): string {
return code ? `${name} (${code})` : 'Not set';
}
back(): void {
this.router.navigate(['/products/dds-account-types']);
}