Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions e2e/loan-product-accounting.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
);
Expand Down
146 changes: 146 additions & 0 deletions e2e/share-product-accounting.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string, string> = {};
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,
});
});
});
1 change: 1 addition & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

Expand Down
Original file line number Diff line number Diff line change
@@ -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<AdvancedAccountingMappingsComponent>;
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());
});
});
Loading