diff --git a/e2e/loan-product-accounting.spec.ts b/e2e/loan-product-accounting.spec.ts index 9adfe21fb..7fc0cf9fa 100644 --- a/e2e/loan-product-accounting.spec.ts +++ b/e2e/loan-product-accounting.spec.ts @@ -138,6 +138,25 @@ test.describe('Loan product accounting', () => { await selectOption(page, slot.label, accounts[slot.accountClass]); } + // --- One payment channel routed to its own fund source ------------------------------------ + // + // Without this override every collection lands in the same fund source, and a till cannot be + // reconciled at close of day. The two charge tables are not exercised here: this form has no + // charge picker, so a product it creates carries none, and the tables correctly say so. + await expect(page.getByTestId('payment-channel-add')).toBeVisible(); + await page.getByTestId('payment-channel-add').click(); + await page.getByTestId('payment-channel-type-0').click(); + await page.locator('ion-popover ion-radio, ion-popover ion-select-option').first().click(); + // Both this row and the base slot are labelled "Fund source", so the row is scoped by its + // own test id rather than by the label text. + await page.getByTestId('payment-channel-account-0').click(); + await page + .locator('ion-popover ion-radio') + .filter({ hasText: accounts['Asset'] }) + .first() + .click(); + await page.keyboard.press('Escape'); + const created = await captureJson<{ resourceId: number }>(page, /\/loanproducts$/, 'POST', () => page.getByRole('button', { name: /^save$/i }).click(), ); diff --git a/e2e/share-product-accounting.spec.ts b/e2e/share-product-accounting.spec.ts new file mode 100644 index 000000000..3af15b9f8 --- /dev/null +++ b/e2e/share-product-accounting.spec.ts @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Share product accounting, end to end against a real Fineract. + * + * The form pinned `accountingRule: 1` on both create and load, so every share product this + * application made was unaccounted and editing an accounted one reset it. Share capital is the + * members' stake in the institution — the one balance-sheet line that cannot be reconstructed + * after the fact. + * + * Two things here are specific to share products and are why this is a separate suite: + * + * - `shareEquityId` is the only **equity** slot in the application, and the share template omits + * `equityAccountOptions` entirely until the tenant holds an equity account. The equity account + * is therefore created through the chart of accounts first, like the other three. + * - The share template carries no `accountingRuleOptions`, so the rule selector is the one in the + * application whose options the form supplies rather than the platform. + * + * npx playwright test e2e/share-product-accounting.spec.ts --project=backend --workers=1 + */ + +import { test, expect, Page } from './fixtures'; +import { login, uniqueSuffix } from './utils/fineract-login'; +import { captureJson } from './utils/capture-response'; +import { selectOption } from './utils/select-option'; + +/** + * The four slots cash accounting requires on a share product, and the class each draws from. + * + * From the platform, not from documentation: posting a share product with `accountingRule: 2` + * and no mappings answers 400 naming exactly these four. + */ +const CASH_SLOTS = [ + { label: 'Share Reference', accountClass: 'Asset' }, + { label: 'Share Suspense Control', accountClass: 'Liability' }, + { label: 'Share Equity', accountClass: 'Equity' }, + { label: 'Income from fees', accountClass: 'Income' }, +] as const; + +const ACCOUNT_CLASSES = ['Asset', 'Liability', 'Equity', 'Income'] as const; + +/** Creates one GL account through the chart of accounts and returns its display name. */ +async function createGlAccount(page: Page, type: string, suffix: string): Promise { + const name = `E2E ${type} ${suffix}`; + const glCode = `E2E-${type.toUpperCase()}-${suffix}`; + + await page.goto('/accounting/chart-of-accounts/create'); + await page.locator('input[name="name"]').fill(name); + await page.locator('input[name="glCode"]').fill(glCode); + await selectOption(page, 'Account Type', type); + await selectOption(page, 'Account Usage', 'Detail'); + await page.getByRole('button', { name: /^save$/i }).click(); + await expect(page).toHaveURL(/\/accounting\/chart-of-accounts$/, { timeout: 20000 }); + + return `${glCode} — ${name}`; +} + +test.describe('Share product accounting', () => { + test.describe.configure({ mode: 'serial' }); + + test('a share product is mapped to equity and round-trips on edit', async ({ page }) => { + test.setTimeout(480000); + await login(page); + + const suffix = uniqueSuffix(); + + const accounts: Record = {}; + for (const type of ACCOUNT_CLASSES) { + accounts[type] = await createGlAccount(page, type, suffix); + } + + // --- Create ------------------------------------------------------------------------------- + const productName = `E2E Share Acc ${suffix}`; + const shortName = suffix.slice(-4).toUpperCase(); + + await page.goto('/products/share/create'); + await expect(page.getByTestId('product-accounting-rule')).toBeVisible({ timeout: 20000 }); + + await page.locator('input[name="name"]').fill(productName); + await page.locator('input[name="shortName"]').fill(shortName); + // Mandatory on the platform even though the form does not mark it so — a share product + // posted without one is refused with validation.msg.shareproduct.description.cannot.be.blank. + await page.locator('textarea[name="description"]').fill('Seeded for accounting coverage'); + + // Nothing accounting-related is on screen under the default rule. + await expect(page.getByTestId('accounting-shareEquityId')).toHaveCount(0); + + // Accrual is deliberately absent: the platform accepts rule 3 on a share product, asks for no + // mappings, and stores an empty mapping set — a product that looks configured and posts + // nothing. Only the two rules that mean what they say are offered. + await selectOption(page, 'Accounting rule', 'Cash'); + + await expect(page.getByTestId('accounting-shareEquityId')).toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('accounting-group-equity')).toBeVisible(); + + for (const slot of CASH_SLOTS) { + await selectOption(page, slot.label, accounts[slot.accountClass]); + } + + const created = await captureJson<{ resourceId: number }>( + page, + /\/products\/share$/, + 'POST', + () => page.getByRole('button', { name: /^save$/i }).click(), + ); + expect(created.resourceId).toBeGreaterThan(0); + await expect(page).toHaveURL(/\/products\/share$/, { timeout: 20000 }); + + // --- Edit must not blank the ledger ------------------------------------------------------- + // + // This is the regression the issue is about: the rule was pinned to 1 on load, so opening a + // configured share product and saving it silently unaccounted it. + await page.goto(`/products/share/edit/${created.resourceId}`); + await expect(page.getByTestId('product-accounting-rule')).toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('accounting-shareEquityId')).toContainText(accounts['Equity'], { + timeout: 20000, + }); + await expect(page.getByTestId('accounting-shareReferenceId')).toContainText(accounts['Asset']); + + await page.locator('input[name="name"]').fill(`${productName} v2`); + await page.getByRole('button', { name: /^save$/i }).click(); + await expect(page).toHaveURL(/\/products\/share$/, { timeout: 20000 }); + + await page.goto(`/products/share/edit/${created.resourceId}`); + await expect(page.getByTestId('accounting-shareEquityId')).toContainText(accounts['Equity'], { + timeout: 20000, + }); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 90668675a..f77651c01 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -28,6 +28,7 @@ const BACKEND_SPECS = [ 'login.spec.ts', 'report-parameter-backend.spec.ts', 'savings-transaction-correction.spec.ts', + 'share-product-accounting.spec.ts', 'teller-cash-management.spec.ts', ]; diff --git a/src/app/features/products/accounting/advanced-accounting-mappings.component.spec.ts b/src/app/features/products/accounting/advanced-accounting-mappings.component.spec.ts new file mode 100644 index 000000000..741b3d1d6 --- /dev/null +++ b/src/app/features/products/accounting/advanced-accounting-mappings.component.spec.ts @@ -0,0 +1,190 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; + +import { AdvancedAccountingMappingsComponent } from './advanced-accounting-mappings.component'; +import { + ACCOUNTING_RULE, + advancedMappingsForRequest, + advancedMappingsFromResponse, + emptyAdvancedMappings, +} from './product-accounting.model'; +import { provideFakeAdapters } from '../../../testing/adapters'; + +const PAYMENT_TYPES = [ + { id: 1, name: 'Cash' }, + { id: 2, name: 'Mobile Money' }, +]; + +const CHARGES = [ + { id: 10, name: 'Processing fee', penalty: false }, + { id: 11, name: 'Insurance fee', penalty: false }, + { id: 20, name: 'Late fee', penalty: true }, +]; + +describe('AdvancedAccountingMappingsComponent', () => { + let fixture: ComponentFixture; + let component: AdvancedAccountingMappingsComponent; + + beforeEach(async () => { + const adapters = provideFakeAdapters(); + + await TestBed.configureTestingModule({ + imports: [AdvancedAccountingMappingsComponent], + providers: [provideNoopAnimations(), ...adapters.providers], + }).compileComponents(); + + fixture = TestBed.createComponent(AdvancedAccountingMappingsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('accountingRule', ACCOUNTING_RULE.CASH); + fixture.componentRef.setInput('paymentTypeOptions', PAYMENT_TYPES); + fixture.componentRef.setInput('charges', CHARGES); + fixture.componentRef.setInput('accountOptions', { + assetAccountOptions: [{ id: 1, name: 'Till', glCode: '10101' }], + incomeAccountOptions: [{ id: 3, name: 'Fee income', glCode: '40101' }], + }); + fixture.componentRef.setInput('mappings', emptyAdvancedMappings()); + fixture.detectChanges(); + }); + + it('stays hidden while the product posts nothing to the ledger', () => { + fixture.componentRef.setInput('accountingRule', ACCOUNTING_RULE.NONE); + fixture.detectChanges(); + + expect(component.visible()).toBeFalse(); + expect(fixture.nativeElement.querySelector('[data-testid="payment-channel-add"]')).toBeNull(); + }); + + it('adds and removes payment channel rows', () => { + component.addPaymentChannel(); + component.setPaymentChannel(0, 'paymentTypeId', 1); + component.setPaymentChannel(0, 'fundSourceAccountId', 1); + + expect(component.mappings().paymentChannelToFundSourceMappings).toEqual([ + { paymentTypeId: 1, fundSourceAccountId: 1 }, + ]); + + component.removePaymentChannel(0); + expect(component.mappings().paymentChannelToFundSourceMappings).toEqual([]); + }); + + /** + * The platform accepts two rows naming the same payment type and then fires neither reliably, + * so the second row must not be able to name it. + */ + it('stops a payment type being routed twice', () => { + component.addPaymentChannel(); + component.setPaymentChannel(0, 'paymentTypeId', 1); + + // Offered to its own row, so the current selection stays visible… + expect(component.paymentChannelRows()[0].paymentTypes.map((option) => option.id)).toEqual([ + 1, 2, + ]); + // …and withheld from any row that has yet to choose. + expect(component.unusedPaymentTypes().map((option) => option.id)).toEqual([2]); + + component.addPaymentChannel(); + component.setPaymentChannel(1, 'paymentTypeId', 2); + expect(component.canAddPaymentChannel()).toBeFalse(); + }); + + /** + * A fee mapping naming a penalty charge is accepted by the platform and never fires, so the + * two tables are drawn from opposite sides of the flag. + */ + it('offers fees and penalties from opposite sides of the penalty flag', () => { + expect(component.chargesFor(false).map((charge) => charge.id)).toEqual([10, 11]); + expect(component.chargesFor(true).map((charge) => charge.id)).toEqual([20]); + }); + + it('reports that there is nothing to map when the product carries no charges', () => { + fixture.componentRef.setInput('charges', []); + fixture.detectChanges(); + + expect(component.chargesFor(false)).toEqual([]); + expect(fixture.nativeElement.querySelector('[data-testid="fee-mapping-none"]')).not.toBeNull(); + }); + + it('keeps the fee and penalty tables apart', () => { + component.addChargeMapping(false); + component.setChargeMapping(false, 0, 'chargeId', 10); + component.addChargeMapping(true); + component.setChargeMapping(true, 0, 'chargeId', 20); + + expect(component.mappings().feeToIncomeAccountMappings).toEqual([{ chargeId: 10 }]); + expect(component.mappings().penaltyToIncomeAccountMappings).toEqual([{ chargeId: 20 }]); + }); + + /** The parent reads this from a signal at submit; a mutation in place would not notify it. */ + it('replaces the mappings object rather than writing into it', () => { + const before = component.mappings(); + component.addPaymentChannel(); + + expect(component.mappings()).not.toBe(before); + }); +}); + +describe('advanced mapping payloads', () => { + it('omits a table that has no complete rows', () => { + const mappings = emptyAdvancedMappings(); + // A row the user added but never filled in must not be posted as `{}`. + mappings.paymentChannelToFundSourceMappings = [{}, { paymentTypeId: 1 }]; + mappings.feeToIncomeAccountMappings = [{ chargeId: 10, incomeAccountId: 3 }]; + + const request = advancedMappingsForRequest(ACCOUNTING_RULE.CASH, mappings); + + expect('paymentChannelToFundSourceMappings' in request).toBeFalse(); + expect(request['feeToIncomeAccountMappings']).toEqual([{ chargeId: 10, incomeAccountId: 3 }]); + expect('penaltyToIncomeAccountMappings' in request).toBeFalse(); + }); + + it('sends nothing at all under a rule that posts nothing', () => { + const mappings = emptyAdvancedMappings(); + mappings.feeToIncomeAccountMappings = [{ chargeId: 10, incomeAccountId: 3 }]; + + expect(advancedMappingsForRequest(ACCOUNTING_RULE.NONE, mappings)).toEqual({}); + }); + + /** + * The request and response disagree about every key. Feeding the response back unchanged would + * show empty selects on a configured product and blank its mappings on the next save. + */ + it('reads the response shape back into the request shape', () => { + const mappings = advancedMappingsFromResponse({ + paymentChannelToFundSourceMappings: [ + { paymentType: { id: 1 }, fundSourceAccount: { id: 7 } }, + ], + feeToIncomeAccountMappings: [{ charge: { id: 10 }, incomeAccount: { id: 3 } }], + penaltyToIncomeAccountMappings: [{ charge: { id: 20 }, incomeAccount: { id: 4 } }], + }); + + expect(mappings.paymentChannelToFundSourceMappings).toEqual([ + { paymentTypeId: 1, fundSourceAccountId: 7 }, + ]); + expect(mappings.feeToIncomeAccountMappings).toEqual([{ chargeId: 10, incomeAccountId: 3 }]); + expect(mappings.penaltyToIncomeAccountMappings).toEqual([{ chargeId: 20, incomeAccountId: 4 }]); + }); + + it('survives a product with no advanced mappings at all', () => { + expect(advancedMappingsFromResponse(undefined)).toEqual(emptyAdvancedMappings()); + expect(advancedMappingsFromResponse({})).toEqual(emptyAdvancedMappings()); + }); +}); diff --git a/src/app/features/products/accounting/advanced-accounting-mappings.component.ts b/src/app/features/products/accounting/advanced-accounting-mappings.component.ts new file mode 100644 index 000000000..01e88ff17 --- /dev/null +++ b/src/app/features/products/accounting/advanced-accounting-mappings.component.ts @@ -0,0 +1,459 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, computed, input, model } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + IonButton, + IonIcon, + IonItem, + IonLabel, + IonSelect, + IonSelectOption, +} from '@ionic/angular/standalone'; + +import { TranslatePipe } from '../../../core/adapters'; +import { + ACCOUNTING_RULES_WITH_MAPPINGS, + AccountingRuleId, + AdvancedAccountingMappings, + GlAccountOption, + GlAccountOptions, +} from './product-accounting.model'; + +/** A payment type as the product template offers it. */ +export interface PaymentTypeOption { + id?: number; + name?: string; +} + +/** A charge as the product template offers it. */ +export interface ChargeOption { + id?: number; + name?: string; + penalty?: boolean; +} + +/** + * The three advanced mapping tables: payment channel to fund source, fee to income, penalty to + * income. + * + * These are overrides on top of the base slots. Without them every payment lands in one fund + * source and every fee in one income account, so a till cannot be reconciled at close of day and + * a processing fee cannot be told apart from an insurance fee after the fact. + * + * **The platform validates almost nothing here**, which is why this component is careful. Checked + * against a live instance, all of the following are accepted silently: + * + * - a fee mapping naming a charge that is not attached to the product; + * - a fee mapping naming a charge whose `penalty` flag is `true`, and vice versa; + * - two rows naming the same payment type. + * + * None of those post anything at run time — they are mappings that can never fire. A rejection + * would at least be visible; silent acceptance produces a product that looks configured and is + * not. So the options offered are narrowed to what can actually work: charges come from the + * product's own selection rather than the tenant's whole catalogue, fees and penalties are drawn + * from opposite sides of the `penalty` flag, and a payment type already mapped is not offered + * again. + */ +@Component({ + selector: 'app-advanced-accounting-mappings', + standalone: true, + imports: [ + FormsModule, + TranslatePipe, + IonButton, + IonIcon, + IonItem, + IonLabel, + IonSelect, + IonSelectOption, + ], + template: ` + @if (visible()) { +
+

{{ 'PRODUCTS.ACCOUNTING.ADVANCED_TITLE' | appTranslate }}

+

{{ 'PRODUCTS.ACCOUNTING.ADVANCED_HINT' | appTranslate }}

+ + +

+ {{ 'PRODUCTS.ACCOUNTING.PAYMENT_CHANNEL_TITLE' | appTranslate }} +

+ @for (entry of paymentChannelRows(); track entry.index) { +
+ + + {{ 'PRODUCTS.ACCOUNTING.PAYMENT_TYPE' | appTranslate }} + + + @for (option of entry.paymentTypes; track option.id) { + {{ option.name }} + } + + + + + + {{ 'PRODUCTS.ACCOUNTING.FUND_SOURCE' | appTranslate }} + + + @for (account of assetAccounts(); track account.id) { + + {{ accountLabel(account) }} + + } + + + + + + +
+ } + + + {{ 'PRODUCTS.ACCOUNTING.ADD_MAPPING' | appTranslate }} + + + + @for (table of chargeTables(); track table.key) { +

{{ table.title | appTranslate }}

+ @if (!table.charges.length) { +

+ {{ 'PRODUCTS.ACCOUNTING.NO_CHARGES_ON_PRODUCT' | appTranslate }} +

+ } + @for (entry of table.rows; track entry.index) { +
+ + {{ table.chargeLabel | appTranslate }} + + @for (charge of table.charges; track charge.id) { + {{ charge.name }} + } + + + + + + {{ 'PRODUCTS.ACCOUNTING.INCOME_ACCOUNT' | appTranslate }} + + + @for (account of incomeAccounts(); track account.id) { + + {{ accountLabel(account) }} + + } + + + + + + +
+ } + + + {{ 'PRODUCTS.ACCOUNTING.ADD_MAPPING' | appTranslate }} + + } +
+ } + `, + styles: [ + ` + .advanced-section { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; + } + .section-title { + margin: 24px 0 0; + font-size: 16px; + font-weight: 600; + } + .table-title { + margin: 16px 0 4px; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted, #6b7280); + } + .section-note { + margin: 0; + font-size: 12px; + color: var(--text-muted, #6b7280); + } + .mapping-row { + display: flex; + gap: 12px; + align-items: flex-end; + width: 100%; + } + .row-field { + flex: 1; + } + `, + ], +}) +export class AdvancedAccountingMappingsComponent { + /** The rule the product carries; the tables are meaningless under `NONE`. */ + readonly accountingRule = input(undefined); + /** `accountingMappingOptions` from the product template. */ + readonly accountOptions = input({}); + /** `paymentTypeOptions` from the product template. */ + readonly paymentTypeOptions = input([]); + /** + * The charges **attached to this product**, not the tenant's catalogue. + * + * Mapping a charge the product does not carry is accepted by the platform and never fires, so + * the parent passes its own selection rather than the template's `chargeOptions`. + */ + readonly charges = input([]); + + readonly mappings = model.required(); + + /** The two charge tables, which differ only by which side of the `penalty` flag they read. */ + private static readonly CHARGE_TABLES = [ + { + key: 'fee-mapping', + penalty: false, + title: 'PRODUCTS.ACCOUNTING.FEE_INCOME_TITLE', + chargeLabel: 'PRODUCTS.ACCOUNTING.FEE', + }, + { + key: 'penalty-mapping', + penalty: true, + title: 'PRODUCTS.ACCOUNTING.PENALTY_INCOME_TITLE', + chargeLabel: 'PRODUCTS.ACCOUNTING.PENALTY', + }, + ] as const; + + /** Hidden under `NONE`: there is no base mapping for these to override. */ + readonly visible = computed(() => + ACCOUNTING_RULES_WITH_MAPPINGS.includes(this.accountingRule() as AccountingRuleId), + ); + + readonly assetAccounts = computed(() => + Array.from(this.accountOptions().assetAccountOptions ?? []), + ); + readonly incomeAccounts = computed(() => + Array.from(this.accountOptions().incomeAccountOptions ?? []), + ); + + /** + * The rows, each carrying the options its own selects may offer. + * + * Computed rather than derived by a method the template calls. A method returning a fresh + * array runs on every change-detection pass and hands back a new identity each time, which the + * dev-mode exhaustive `checkNoChanges` pass reports as NG0100 — and the e2e fixtures fail the + * run on that, deliberately. A computed settles once per dependency change instead. + */ + readonly paymentChannelRows = computed(() => { + const rows = this.mappings().paymentChannelToFundSourceMappings; + // A payment type may be routed once. Each row still offers its own current selection, or + // choosing it would remove it from the list it was chosen from. + const taken = new Set( + rows.map((row) => row.paymentTypeId).filter((id): id is number => id != null), + ); + + return rows.map((row, index) => ({ + row, + index, + paymentTypes: this.paymentTypeOptions().filter( + (option) => option.id == null || !taken.has(option.id) || option.id === row.paymentTypeId, + ), + })); + }); + + /** Payment types not yet routed, which is what decides whether another row can be added. */ + readonly unusedPaymentTypes = computed(() => { + const taken = new Set( + this.mappings() + .paymentChannelToFundSourceMappings.map((row) => row.paymentTypeId) + .filter((id): id is number => id != null), + ); + return this.paymentTypeOptions().filter((option) => option.id == null || !taken.has(option.id)); + }); + + readonly feeCharges = computed(() => this.charges().filter((charge) => !charge.penalty)); + readonly penaltyCharges = computed(() => + this.charges().filter((charge) => Boolean(charge.penalty)), + ); + + readonly chargeTables = computed(() => + AdvancedAccountingMappingsComponent.CHARGE_TABLES.map((table) => ({ + ...table, + charges: table.penalty ? this.penaltyCharges() : this.feeCharges(), + rows: (table.penalty + ? this.mappings().penaltyToIncomeAccountMappings + : this.mappings().feeToIncomeAccountMappings + ).map((row, index) => ({ row, index })), + })), + ); + + canAddPaymentChannel(): boolean { + return this.unusedPaymentTypes().length > 0; + } + + chargesFor(penalty: boolean): ChargeOption[] { + return penalty ? this.penaltyCharges() : this.feeCharges(); + } + + rowsFor(penalty: boolean) { + return penalty + ? this.mappings().penaltyToIncomeAccountMappings + : this.mappings().feeToIncomeAccountMappings; + } + + accountLabel(account: GlAccountOption): string { + return account.glCode ? `${account.glCode} — ${account.name}` : (account.name ?? ''); + } + + addPaymentChannel(): void { + this.patch({ + paymentChannelToFundSourceMappings: [ + ...this.mappings().paymentChannelToFundSourceMappings, + {}, + ], + }); + } + + removePaymentChannel(index: number): void { + this.patch({ + paymentChannelToFundSourceMappings: this.mappings().paymentChannelToFundSourceMappings.filter( + (_, i) => i !== index, + ), + }); + } + + setPaymentChannel( + index: number, + key: 'paymentTypeId' | 'fundSourceAccountId', + value: number, + ): void { + this.patch({ + paymentChannelToFundSourceMappings: this.mappings().paymentChannelToFundSourceMappings.map( + (row, i) => (i === index ? { ...row, [key]: value } : row), + ), + }); + } + + addChargeMapping(penalty: boolean): void { + this.patchChargeTable(penalty, [...this.rowsFor(penalty), {}]); + } + + removeChargeMapping(penalty: boolean, index: number): void { + this.patchChargeTable( + penalty, + this.rowsFor(penalty).filter((_, i) => i !== index), + ); + } + + setChargeMapping( + penalty: boolean, + index: number, + key: 'chargeId' | 'incomeAccountId', + value: number, + ): void { + this.patchChargeTable( + penalty, + this.rowsFor(penalty).map((row, i) => (i === index ? { ...row, [key]: value } : row)), + ); + } + + private patchChargeTable( + penalty: boolean, + rows: AdvancedAccountingMappings['feeToIncomeAccountMappings'], + ): void { + this.patch( + penalty ? { penaltyToIncomeAccountMappings: rows } : { feeToIncomeAccountMappings: rows }, + ); + } + + /** + * Replaces the whole object rather than writing into it. + * + * The parent holds this in a signal and reads it at submit; mutating in place would leave that + * signal unchanged by identity and nothing downstream would recompute. + */ + private patch(change: Partial): void { + this.mappings.set({ ...this.mappings(), ...change }); + } +} diff --git a/src/app/features/products/accounting/product-accounting-section.component.spec.ts b/src/app/features/products/accounting/product-accounting-section.component.spec.ts index a0327c21a..a0c45e5dd 100644 --- a/src/app/features/products/accounting/product-accounting-section.component.spec.ts +++ b/src/app/features/products/accounting/product-accounting-section.component.spec.ts @@ -25,6 +25,9 @@ import { ACCOUNTING_RULE, GlAccountOptions, LOAN_ACCOUNTING_FIELDS, + SHARE_ACCOUNTING_FIELDS, + SHARE_ACCOUNTING_RULES, + fieldsForRule, mappingsForRule, mappingsFromResponse, } from './product-accounting.model'; @@ -210,3 +213,67 @@ describe('product accounting payload', () => { expect(mappingsFromResponse(LOAN_ACCOUNTING_FIELDS, undefined)).toEqual({}); }); }); + +/** + * Share products, which differ from the other four families in three ways that all bite. + */ +describe('share product accounting', () => { + it('maps four slots under cash, including the only equity slot in the application', () => { + const fields = fieldsForRule(SHARE_ACCOUNTING_FIELDS, ACCOUNTING_RULE.CASH); + + expect(fields.map((field) => field.key)).toEqual([ + 'shareReferenceId', + 'shareSuspenseId', + 'shareEquityId', + 'incomeFromFeeAccountId', + ]); + // Pointing this at a liability is refused with 403 validation.msg.domain.rule.violation. + expect(fields.find((field) => field.key === 'shareEquityId')?.accountType).toBe('equity'); + }); + + it('maps nothing under none', () => { + expect(fieldsForRule(SHARE_ACCOUNTING_FIELDS, ACCOUNTING_RULE.NONE)).toEqual([]); + }); + + /** + * The platform's validator accepts 1, 2 and 3, but a share product created under rule 3 asks + * for no mappings and reads back with `accountingMappings: {}` — configured-looking and + * posting nothing. Only the rules that mean what they say are offered. + */ + it('offers none and cash only', () => { + expect(SHARE_ACCOUNTING_RULES).toEqual([ACCOUNTING_RULE.NONE, ACCOUNTING_RULE.CASH]); + expect(SHARE_ACCOUNTING_RULES).not.toContain(ACCOUNTING_RULE.ACCRUAL_PERIODIC); + }); + + /** + * Share products read back under the same keys they are written with; every other family drops + * the `Id` suffix on the way out. + */ + it('reads a configured product back from identically named keys', () => { + const mappings = mappingsFromResponse(SHARE_ACCOUNTING_FIELDS, { + shareReferenceId: { id: 7, name: 'Share reference' }, + shareSuspenseId: { id: 8, name: 'Share suspense' }, + shareEquityId: { id: 27, name: 'Share equity' }, + incomeFromFeeAccountId: { id: 9, name: 'Fee income' }, + }); + + expect(mappings).toEqual({ + shareReferenceId: 7, + shareSuspenseId: 8, + shareEquityId: 27, + incomeFromFeeAccountId: 9, + }); + }); + + it('sends the four ids under cash and none under none', () => { + const chosen = { + shareReferenceId: 7, + shareSuspenseId: 8, + shareEquityId: 27, + incomeFromFeeAccountId: 9, + }; + + expect(mappingsForRule(SHARE_ACCOUNTING_FIELDS, ACCOUNTING_RULE.CASH, chosen)).toEqual(chosen); + expect(mappingsForRule(SHARE_ACCOUNTING_FIELDS, ACCOUNTING_RULE.NONE, chosen)).toEqual({}); + }); +}); diff --git a/src/app/features/products/accounting/product-accounting-section.component.ts b/src/app/features/products/accounting/product-accounting-section.component.ts index 41ff4adbd..e85c054b6 100644 --- a/src/app/features/products/accounting/product-accounting-section.component.ts +++ b/src/app/features/products/accounting/product-accounting-section.component.ts @@ -58,12 +58,13 @@ interface MappingGroup { const GROUP_LABELS: Record = { asset: 'ACCOUNTING.ASSET', liability: 'ACCOUNTING.LIABILITY', + equity: 'ACCOUNTING.EQUITY', income: 'ACCOUNTING.INCOME', expense: 'ACCOUNTING.EXPENSE', }; /** Rendering order, which follows the order a trial balance is read in. */ -const GROUP_ORDER: readonly GlAccountType[] = ['asset', 'liability', 'income', 'expense']; +const GROUP_ORDER: readonly GlAccountType[] = ['asset', 'liability', 'equity', 'income', 'expense']; /** * The accounting block of a product form: pick a rule, then map the accounts it requires. @@ -211,6 +212,8 @@ export class ProductAccountingSectionComponent { return Array.from(options.assetAccountOptions ?? []); case 'liability': return Array.from(options.liabilityAccountOptions ?? []); + case 'equity': + return Array.from(options.equityAccountOptions ?? []); case 'income': return Array.from(options.incomeAccountOptions ?? []); case 'expense': diff --git a/src/app/features/products/accounting/product-accounting.model.ts b/src/app/features/products/accounting/product-accounting.model.ts index 00061c83f..643570453 100644 --- a/src/app/features/products/accounting/product-accounting.model.ts +++ b/src/app/features/products/accounting/product-accounting.model.ts @@ -64,7 +64,7 @@ export const ACCRUAL_RULES: readonly AccountingRuleId[] = [ * maps to the account Probe INC of type INCOME, the expected account type was ASSET" — so the * class each slot expects is what makes the picker able to only offer accounts that will work. */ -export type GlAccountType = 'asset' | 'liability' | 'income' | 'expense'; +export type GlAccountType = 'asset' | 'liability' | 'equity' | 'income' | 'expense'; /** A GL account as the product template offers it. */ export interface GlAccountOption { @@ -84,6 +84,7 @@ export interface GlAccountOption { export interface GlAccountOptions { assetAccountOptions?: Iterable; liabilityAccountOptions?: Iterable; + equityAccountOptions?: Iterable; incomeAccountOptions?: Iterable; expenseAccountOptions?: Iterable; } @@ -124,6 +125,8 @@ export type AccountingMappings = Record; * * POST /loanproducts {"accountingRule": 2, …no mapping ids…} */ +const INCOME_FROM_FEES_LABEL = 'PRODUCTS.ACCOUNTING.INCOME_FROM_FEES'; + export const LOAN_ACCOUNTING_FIELDS: readonly AccountingMappingField[] = [ { key: 'fundSourceAccountId', @@ -178,7 +181,7 @@ export const LOAN_ACCOUNTING_FIELDS: readonly AccountingMappingField[] = [ key: 'incomeFromFeeAccountId', responseKey: 'incomeFromFeeAccount', accountType: 'income', - label: 'PRODUCTS.ACCOUNTING.INCOME_FROM_FEES', + label: INCOME_FROM_FEES_LABEL, rules: ACCOUNTING_RULES_WITH_MAPPINGS, }, { @@ -336,7 +339,7 @@ export const SAVINGS_ACCOUNTING_FIELDS: readonly AccountingMappingField[] = [ key: 'incomeFromFeeAccountId', responseKey: 'incomeFromFeeAccount', accountType: 'income', - label: 'PRODUCTS.ACCOUNTING.INCOME_FROM_FEES', + label: INCOME_FROM_FEES_LABEL, rules: ACCOUNTING_RULES_WITH_MAPPINGS, }, { @@ -384,3 +387,197 @@ export const TERM_DEPOSIT_ACCOUNTING_FIELDS: readonly AccountingMappingField[] = field.key, ), ); + +/** + * The rules a **share product** may carry. + * + * The share template is the one product template that returns no `accountingRuleOptions`, so + * unlike the other families this list cannot come from the platform and is stated here. + * + * `NONE` and `CASH` only, and the omission of accrual is deliberate rather than conservative. + * The platform's validator accepts 1, 2 and 3 — a 4 is refused with + * `validation.msg.shareproduct.accountingRule.is.not.within.expected.range`, "must be between 1 + * and 3" — but a share product created with rule 3 asks for no mappings at all and reads back + * with `accountingMappings: {}`. That is a product labelled ACCRUAL PERIODIC which posts nothing, + * which is worse than one labelled NONE: it looks configured. Only the two rules that mean what + * they say are offered. + */ +export const SHARE_ACCOUNTING_RULES: readonly AccountingRuleId[] = [ + ACCOUNTING_RULE.NONE, + ACCOUNTING_RULE.CASH, +]; + +/** + * The slots a **share product** maps. + * + * Enumerated from the platform the same way as the other families, and it names four rather than + * the three the equity story suggests: + * + * POST /products/share {"accountingRule": 2, …no mapping ids…} + * → shareReferenceId, shareSuspenseId, incomeFromFeeAccountId, shareEquityId + * + * `shareEquityId` is the members' stake itself and is the only slot in the application that takes + * an **equity** account. That matters twice over: pointing it at a liability is refused with + * `403 validation.msg.domain.rule.violation`, and the share template omits + * `equityAccountOptions` entirely until the tenant has at least one equity account — Fineract + * drops an option list rather than sending it empty. A fresh chart of accounts therefore offers + * nothing here, which {@link ProductAccountingSectionComponent} says out loud. + * + * Note the `responseKey`s: share products read back under the **same** keys they were written + * with, `Id` suffix and all. Every other family drops the suffix — `fundSourceAccountId` in, + * `fundSourceAccount` out — so this is the one field list where the two spellings coincide. + */ +export const SHARE_ACCOUNTING_FIELDS: readonly AccountingMappingField[] = [ + { + key: 'shareReferenceId', + responseKey: 'shareReferenceId', + accountType: 'asset', + label: 'PRODUCTS.ACCOUNTING.SHARE_REFERENCE', + rules: [ACCOUNTING_RULE.CASH], + }, + { + key: 'shareSuspenseId', + responseKey: 'shareSuspenseId', + accountType: 'liability', + label: 'PRODUCTS.ACCOUNTING.SHARE_SUSPENSE', + rules: [ACCOUNTING_RULE.CASH], + }, + { + key: 'shareEquityId', + responseKey: 'shareEquityId', + accountType: 'equity', + label: 'PRODUCTS.ACCOUNTING.SHARE_EQUITY', + rules: [ACCOUNTING_RULE.CASH], + }, + { + key: 'incomeFromFeeAccountId', + responseKey: 'incomeFromFeeAccountId', + accountType: 'income', + label: INCOME_FROM_FEES_LABEL, + rules: [ACCOUNTING_RULE.CASH], + }, +]; + +// --- Advanced mappings ------------------------------------------------------------------------ +// +// The base slots above send every payment to one fund source and every fee to one income +// account. These three tables override that per payment channel and per charge, which is what +// makes a till reconcilable at close of day and a fee line reportable on its own. + +/** One payment channel routed to its own fund source. */ +export interface PaymentChannelMapping { + paymentTypeId?: number; + fundSourceAccountId?: number; +} + +/** One charge routed to its own income account. Used for both fees and penalties. */ +export interface ChargeToIncomeMapping { + chargeId?: number; + incomeAccountId?: number; +} + +/** The three advanced tables a product form holds. */ +export interface AdvancedAccountingMappings { + paymentChannelToFundSourceMappings: PaymentChannelMapping[]; + feeToIncomeAccountMappings: ChargeToIncomeMapping[]; + penaltyToIncomeAccountMappings: ChargeToIncomeMapping[]; +} + +/** An empty set of advanced tables. */ +export function emptyAdvancedMappings(): AdvancedAccountingMappings { + return { + paymentChannelToFundSourceMappings: [], + feeToIncomeAccountMappings: [], + penaltyToIncomeAccountMappings: [], + }; +} + +/** A row is worth sending only once both halves are chosen. */ +function isCompletePaymentChannel(row: PaymentChannelMapping): boolean { + return row.paymentTypeId != null && row.fundSourceAccountId != null; +} + +function isCompleteChargeMapping(row: ChargeToIncomeMapping): boolean { + return row.chargeId != null && row.incomeAccountId != null; +} + +/** + * The advanced tables as the create/update request carries them. + * + * Half-filled rows are dropped rather than sent: the tables are edited in place, so a user who + * adds a row and then picks nothing would otherwise post `{}` into an array the platform reads + * positionally. + * + * Empty tables produce **no key at all**. Both spellings are in fact accepted here — a loan + * product posts identically with `[]` and with the key absent — but omitting keeps the request + * to what the user actually configured, and matches how {@link mappingsForRule} treats the base + * slots. + * + * A rule with no mappings gets nothing, for the same reason the base slots do. + */ +export function advancedMappingsForRequest( + rule: number | undefined, + mappings: AdvancedAccountingMappings, +): Record { + if (!ACCOUNTING_RULES_WITH_MAPPINGS.includes(rule as AccountingRuleId)) return {}; + + const request: Record = {}; + + const channels = mappings.paymentChannelToFundSourceMappings.filter(isCompletePaymentChannel); + if (channels.length) request['paymentChannelToFundSourceMappings'] = channels; + + const fees = mappings.feeToIncomeAccountMappings.filter(isCompleteChargeMapping); + if (fees.length) request['feeToIncomeAccountMappings'] = fees; + + const penalties = mappings.penaltyToIncomeAccountMappings.filter(isCompleteChargeMapping); + if (penalties.length) request['penaltyToIncomeAccountMappings'] = penalties; + + return request; +} + +/** The response shape, which does not match the request shape. */ +interface AdvancedMappingResponse { + paymentChannelToFundSourceMappings?: { + paymentType?: { id?: number }; + fundSourceAccount?: { id?: number }; + }[]; + feeToIncomeAccountMappings?: { charge?: { id?: number }; incomeAccount?: { id?: number } }[]; + penaltyToIncomeAccountMappings?: { charge?: { id?: number }; incomeAccount?: { id?: number } }[]; +} + +/** + * The advanced tables a loaded product already has. + * + * The request and the response disagree about every key, which is the trap this exists for. A + * product is written with `{paymentTypeId, fundSourceAccountId}` and reads back as + * `{paymentType: {id}, fundSourceAccount: {id}}`; fees and penalties are written with + * `{chargeId, incomeAccountId}` and read back as `{charge: {id}, incomeAccount: {id}}`. A form + * that fed the response straight back into its model would show empty selects on a configured + * product and then blank the mappings on save. + */ +export function advancedMappingsFromResponse( + product: object | undefined, +): AdvancedAccountingMappings { + const mappings = emptyAdvancedMappings(); + if (!product) return mappings; + + const source = product as AdvancedMappingResponse; + + mappings.paymentChannelToFundSourceMappings = ( + source.paymentChannelToFundSourceMappings ?? [] + ).map((row) => ({ + paymentTypeId: row.paymentType?.id, + fundSourceAccountId: row.fundSourceAccount?.id, + })); + + mappings.feeToIncomeAccountMappings = (source.feeToIncomeAccountMappings ?? []).map((row) => ({ + chargeId: row.charge?.id, + incomeAccountId: row.incomeAccount?.id, + })); + + mappings.penaltyToIncomeAccountMappings = (source.penaltyToIncomeAccountMappings ?? []).map( + (row) => ({ chargeId: row.charge?.id, incomeAccountId: row.incomeAccount?.id }), + ); + + return mappings; +} diff --git a/src/app/features/products/loan-product-form.component.ts b/src/app/features/products/loan-product-form.component.ts index 8ab2a6cf9..79dab1eba 100644 --- a/src/app/features/products/loan-product-form.component.ts +++ b/src/app/features/products/loan-product-form.component.ts @@ -59,6 +59,11 @@ import { import { LOAN_SCHEDULE_TYPE, isAdvancedPaymentAllocationStrategy } from './loan-schedule-type'; import { PaymentCreditAllocationEditorComponent } from './payment-credit-allocation-editor.component'; import { ProductAccountingSectionComponent } from './accounting/product-accounting-section.component'; +import { + AdvancedAccountingMappingsComponent, + ChargeOption, + PaymentTypeOption, +} from './accounting/advanced-accounting-mappings.component'; import { ACCOUNTING_RULE, AccountingMappings, @@ -66,6 +71,10 @@ import { LOAN_ACCOUNTING_FIELDS, mappingsForRule, mappingsFromResponse, + AdvancedAccountingMappings, + advancedMappingsForRequest, + advancedMappingsFromResponse, + emptyAdvancedMappings, } from './accounting/product-accounting.model'; import { TooltipDirective } from '../../shared/directives/tooltip.directive'; @@ -85,6 +94,7 @@ const DAILY_INTEREST_CALCULATION_PERIOD = 0; FormsModule, TranslateModule, ProductAccountingSectionComponent, + AdvancedAccountingMappingsComponent, IonCard, IonCardHeader, IonCardTitle, @@ -1223,6 +1233,15 @@ const DAILY_INTEREST_CALCULATION_PERIOD = 0; (mappingsChange)="accountingMappings.set($event)" > + +
([]); readonly accountingMappings = signal({}); + /** + * The advanced overrides: payment channel to fund source, fee and penalty to income. + * + * Held separately from `accountingMappings` because they are arrays the user edits in place + * rather than a flat set of ids, and because a rule of `NONE` must drop them the same way. + */ + readonly advancedMappings = signal(emptyAdvancedMappings()); + readonly paymentTypeOptions = signal([]); + + /** + * The charges this product carries, which is what the fee and penalty tables may map. + * + * Read from the loaded product rather than from the template's catalogue. The platform accepts + * a mapping for a charge the product does not carry and then never fires it, so offering the + * tenant's whole charge list would invite exactly that. This form has no charge picker of its + * own, so on create the tables correctly report that there is nothing to map yet. + */ + readonly productCharges = signal([]); + readonly product = signal({ currencyCode: 'USD', digitsAfterDecimal: 2, @@ -1437,6 +1475,7 @@ export class LoanProductFormComponent implements OnInit { this.chargeOffBehaviourOptions.set(template.chargeOffBehaviourOptions ?? []); this.accountingMappingOptions.set(template.accountingMappingOptions ?? {}); this.accountingRuleOptions.set(Array.from(template.accountingRuleOptions ?? [])); + this.paymentTypeOptions.set(Array.from(template.paymentTypeOptions ?? [])); this.applyTransactionProcessingStrategyFilter(); }); @@ -1766,6 +1805,8 @@ export class LoanProductFormComponent implements OnInit { this.accountingMappings.set( mappingsFromResponse(this.accountingFields, data.accountingMappings), ); + this.advancedMappings.set(advancedMappingsFromResponse(data)); + this.productCharges.set((data.charges ?? []) as ChargeOption[]); this.isProgressive.set(this.product().loanScheduleType === LOAN_SCHEDULE_TYPE.PROGRESSIVE); this.downPaymentEnabled.set(this.product().enableDownPayment === true); this.multiDisburseEnabled.set(this.product().multiDisburseLoan === true); @@ -1793,6 +1834,7 @@ export class LoanProductFormComponent implements OnInit { this.product().accountingRule ?? ACCOUNTING_RULE.NONE, this.accountingMappings(), ), + ...advancedMappingsForRequest(this.product().accountingRule, this.advancedMappings()), }; // `enableAutoRepaymentForDownPayment` is only a valid parameter while down payment is on. diff --git a/src/app/features/products/savings-product-form.component.ts b/src/app/features/products/savings-product-form.component.ts index af5e4e022..afe849ade 100644 --- a/src/app/features/products/savings-product-form.component.ts +++ b/src/app/features/products/savings-product-form.component.ts @@ -45,6 +45,11 @@ import { PutSavingsProductsProductIdRequest, } from '../../api'; import { ProductAccountingSectionComponent } from './accounting/product-accounting-section.component'; +import { + AdvancedAccountingMappingsComponent, + ChargeOption, + PaymentTypeOption, +} from './accounting/advanced-accounting-mappings.component'; import { ACCOUNTING_RULE, AccountingMappings, @@ -52,6 +57,10 @@ import { SAVINGS_ACCOUNTING_FIELDS, mappingsForRule, mappingsFromResponse, + AdvancedAccountingMappings, + advancedMappingsForRequest, + advancedMappingsFromResponse, + emptyAdvancedMappings, } from './accounting/product-accounting.model'; @Component({ @@ -61,6 +70,7 @@ import { FormsModule, TranslateModule, ProductAccountingSectionComponent, + AdvancedAccountingMappingsComponent, IonCard, IonCardHeader, IonCardTitle, @@ -206,6 +216,15 @@ import { (mappingsChange)="accountingMappings.set($event)" > + +
([]); readonly accountingMappings = signal({}); + /** The advanced overrides — see the loan form for why these are held apart from the base ids. */ + readonly advancedMappings = signal(emptyAdvancedMappings()); + readonly paymentTypeOptions = signal([]); + /** Charges the product carries; the tenant catalogue would invite mappings that never fire. */ + readonly productCharges = signal([]); + readonly product = signal({ currencyCode: 'USD', digitsAfterDecimal: 2, @@ -297,6 +322,9 @@ export class SavingsProductFormComponent implements OnInit { this.accountingRuleOptions.set( Array.from((raw['accountingRuleOptions'] as { id?: number; value?: string }[]) ?? []), ); + this.paymentTypeOptions.set( + Array.from((raw['paymentTypeOptions'] as PaymentTypeOption[]) ?? []), + ); }); this.route.paramMap.subscribe((params) => { @@ -335,6 +363,10 @@ export class SavingsProductFormComponent implements OnInit { (data as unknown as Record)['accountingMappings'] as object, ), ); + this.advancedMappings.set(advancedMappingsFromResponse(data)); + this.productCharges.set( + ((data as unknown as Record)['charges'] as ChargeOption[]) ?? [], + ); }); } @@ -342,6 +374,7 @@ export class SavingsProductFormComponent implements OnInit { private buildRequest(): PostSavingsProductsRequest { return { ...this.product(), + ...advancedMappingsForRequest(this.product().accountingRule, this.advancedMappings()), ...mappingsForRule( this.accountingFields, this.product().accountingRule ?? ACCOUNTING_RULE.NONE, diff --git a/src/app/features/products/shares/share-product-form.component.ts b/src/app/features/products/shares/share-product-form.component.ts index 1e031701c..f403de205 100644 --- a/src/app/features/products/shares/share-product-form.component.ts +++ b/src/app/features/products/shares/share-product-form.component.ts @@ -39,6 +39,17 @@ import { IonCol, } from '@ionic/angular/standalone'; import { ProductsService, PostProductsTypeRequest } from '../../../api'; +import { I18N } from '../../../core/adapters'; +import { ProductAccountingSectionComponent } from '../accounting/product-accounting-section.component'; +import { + ACCOUNTING_RULE, + AccountingMappings, + GlAccountOptions, + SHARE_ACCOUNTING_FIELDS, + SHARE_ACCOUNTING_RULES, + mappingsForRule, + mappingsFromResponse, +} from '../accounting/product-accounting.model'; const DEFAULT_CURRENCY = 'USD'; const DEFAULT_LOCALE = 'en'; @@ -51,6 +62,7 @@ const PRODUCT_TYPE = 'share'; imports: [ FormsModule, TranslateModule, + ProductAccountingSectionComponent, IonCard, IonCardHeader, IonCardTitle, @@ -205,6 +217,16 @@ const PRODUCT_TYPE = 'share'; + +
({}); + readonly accountingMappings = signal({}); + + /** + * The rule selector's options. + * + * Every other product family reads these from `accountingRuleOptions` on its template. The + * share template does not carry that key, so the two rules a share product can meaningfully + * hold are named here — see {@link SHARE_ACCOUNTING_RULES} for why accrual is not among them. + */ + readonly ruleOptions = SHARE_ACCOUNTING_RULES.map((id) => ({ + id, + value: this.i18n.translate( + id === ACCOUNTING_RULE.NONE + ? 'PRODUCTS.ACCOUNTING.RULE_NONE' + : 'PRODUCTS.ACCOUNTING.RULE_CASH', + ), + })); + readonly product = signal({ currencyCode: DEFAULT_CURRENCY, digitsAfterDecimal: 2, @@ -279,11 +322,12 @@ export class ShareProductFormComponent implements OnInit { totalShares: 1000, unitPrice: 1, nominalShares: 1, - accountingRule: 1, + accountingRule: ACCOUNTING_RULE.NONE, allowDividendCalculationForInactiveClients: false, }); ngOnInit() { + this.loadTemplate(); this.route.paramMap.subscribe((params) => { const id = params.get('id'); if (id) { @@ -294,6 +338,25 @@ export class ShareProductFormComponent implements OnInit { }); } + /** + * Reads the chart of accounts the mapping selects offer. + * + * The response is typed `string` by the document although it is JSON, hence the cast. Note + * that Fineract omits an option list entirely when the tenant holds no accounts of that class + * — a chart with no equity account returns no `equityAccountOptions` at all, rather than an + * empty array — so the section is written to tolerate any of them being absent. + */ + private loadTemplate(): void { + this.productService.getProductsTypeTemplate(PRODUCT_TYPE).subscribe({ + next: (template) => { + const options = (template as unknown as { accountingMappingOptions?: GlAccountOptions }) + .accountingMappingOptions; + this.accountOptions.set(options ?? {}); + }, + error: () => this.accountOptions.set({}), + }); + } + loadProductData() { if (!this.productId) return; this.productService.getProductsTypeProductId(this.productId, PRODUCT_TYPE).subscribe((data) => { @@ -306,24 +369,50 @@ export class ShareProductFormComponent implements OnInit { totalShares: data.totalShares, unitPrice: data.unitPrice, nominalShares: data.nominalShares, - accountingRule: 1, + accountingRule: data.accountingRule?.id ?? ACCOUNTING_RULE.NONE, }); + this.accountingMappings.set( + mappingsFromResponse( + this.accountingFields, + (data as unknown as { accountingMappings?: object }).accountingMappings, + ), + ); }); } + /** + * The product plus the mapping ids the selected rule requires, and no others. + * + * `accountingRule` used to be pinned to `1` here and on load, so every share product this + * screen created was unaccounted and editing an accounted one silently reset it. Share capital + * is the members' stake in the institution; posting nothing against it is the one line on the + * balance sheet that cannot be reconstructed afterwards. + */ + private buildRequest(): PostProductsTypeRequest { + return { + ...this.product(), + locale: DEFAULT_LOCALE, + ...mappingsForRule( + this.accountingFields, + this.product().accountingRule ?? ACCOUNTING_RULE.NONE, + this.accountingMappings(), + ), + }; + } + onSubmit() { this.isSaving.set(true); - this.product().locale = DEFAULT_LOCALE; + const request = this.buildRequest(); if (this.isEditMode() && this.productId) { this.productService - .putProductsTypeProductId(PRODUCT_TYPE, this.productId, this.product()) + .putProductsTypeProductId(PRODUCT_TYPE, this.productId, request) .subscribe({ next: () => this.router.navigate([REDIRECT_URL]), error: () => this.isSaving.set(false), }); } else { - this.productService.postProductsType(PRODUCT_TYPE, this.product()).subscribe({ + this.productService.postProductsType(PRODUCT_TYPE, request).subscribe({ next: () => this.router.navigate([REDIRECT_URL]), error: () => this.isSaving.set(false), }); diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index bc8ce444c..90dfb07a8 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -413,7 +413,13 @@ "ENABLE_ACCRUAL_ACTIVITY_POSTING": "Post accrual activity entries", "INTEREST_CALC_PERIOD_LOCKED_NOTE": "Fixed to Daily because interest recalculation is on — Fineract only supports the two together. Turn recalculation off to choose a different period.", "ACCOUNTING": { + "ADD_MAPPING": "Add Mapping", + "ADVANCED_HINT": "Optional. Route individual payment channels and charges to their own accounts, overriding the mappings above.", + "ADVANCED_TITLE": "Advanced Accounting Rules", + "FEE": "Fee", + "FEE_INCOME_TITLE": "Map Fees to Specific Income Accounts", "FUND_SOURCE": "Fund source", + "INCOME_ACCOUNT": "Income Account", "INCOME_FROM_FEES": "Income from fees", "INCOME_FROM_INTEREST": "Income from interest", "INCOME_FROM_PENALTIES": "Income from penalties", @@ -424,15 +430,25 @@ "LOAN_PORTFOLIO": "Loan portfolio", "LOSSES_WRITTEN_OFF": "Losses written off", "NO_ACCOUNTS_OF_TYPE": "No general ledger accounts of this type exist yet. Create them under Accounting > Chart of Accounts before configuring this product.", + "NO_CHARGES_ON_PRODUCT": "Attach a charge to this product before it can be mapped to its own account.", "OVERDRAFT_PORTFOLIO": "Overdraft portfolio control", "OVERPAYMENT_LIABILITY": "Overpayment liability", + "PAYMENT_CHANNEL_TITLE": "Configure Fund Sources for Payment Channels", + "PAYMENT_TYPE": "Payment Type", + "PENALTY": "Penalty", + "PENALTY_INCOME_TITLE": "Map Penalties to Specific Income Accounts", "RECEIVABLE_FEE": "Fees receivable", "RECEIVABLE_INTEREST": "Interest receivable", "RECEIVABLE_PENALTY": "Penalties receivable", "RULE": "Accounting rule", + "RULE_CASH": "Cash", + "RULE_NONE": "None", "SAVINGS_CONTROL": "Savings control", "SAVINGS_REFERENCE": "Savings reference", "SELECT_ACCOUNT": "Select an account", + "SHARE_EQUITY": "Share Equity", + "SHARE_REFERENCE": "Share Reference", + "SHARE_SUSPENSE": "Share Suspense Control", "TITLE": "Accounting", "TRANSFERS_IN_SUSPENSE": "Transfers in suspense" },