Skip to content
Open
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
94 changes: 94 additions & 0 deletions src/app/loans/loans.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { LoansService } from './loans.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';

describe('LoansService', () => {
let service: LoansService;
let httpMock: HttpTestingController;

beforeEach(() => {
const mockSettings: Partial<SettingsService> = {
language: { name: 'English', code: 'en' },
dateFormat: 'yyyy-MM-dd',
businessDate: new Date(),
maxFutureDate: new Date()
};
const mockDates: Partial<Dates> = {
formatDate: (d: any) => d,
formatDateAsString: (d: any) => String(d)
} as any;

TestBed.configureTestingModule({
providers: [
LoansService,
provideHttpClient(),
provideHttpClientTesting(),
{ provide: SettingsService, useValue: mockSettings },
{ provide: Dates, useValue: mockDates }]
});
service = TestBed.inject(LoansService);
httpMock = TestBed.inject(HttpTestingController);
});

afterEach(() => {
httpMock.verify();
});

it('should sanitize all GSIM fields before posting calculateLoanSchedule', () => {
const payload = {
principal: 1000,
linkAccountId: 42,
linkAccountOwnerName: 'Alice',
linkedSavingsAccount: { id: 5 },
nested: {
linkAccountOwnerId: 7,
linkAccountOwnerName: 'Bob',
ok: 'keep'
},
members: [
{ id: 1, linkAccountId: 99 },
{ id: 2, name: 'foo', linkedSavingsAccount: { id: 9 } }
]
} as any;

const original = JSON.parse(JSON.stringify(payload));

service.calculateLoanSchedule(payload).subscribe(() => {});

const req = httpMock.expectOne('/loans?command=calculateLoanSchedule');
expect(req.request.method).toBe('POST');

const body = req.request.body;

// All forbidden keys should be removed at every depth
expect(body.linkAccountId).toBeUndefined();
expect(body.linkAccountOwnerName).toBeUndefined();
expect(body.linkedSavingsAccount).toBeUndefined();
expect(body.nested.linkAccountOwnerId).toBeUndefined();
expect(body.nested.linkAccountOwnerName).toBeUndefined();
expect(body.members[0].linkAccountId).toBeUndefined();
expect(body.members[1].linkedSavingsAccount).toBeUndefined();

// Allowed keys should be preserved
expect(body.principal).toBe(1000);
expect(body.nested.ok).toBe('keep');
expect(body.members[1].name).toBe('foo');

// Original caller payload must remain unmutated
expect(payload).toEqual(original);

req.flush({});
});

it('should keep non-object primitives intact', () => {
const payload = { value: 1, text: 'abc' };
service.calculateLoanSchedule(payload).subscribe(() => {});
const req = httpMock.expectOne('/loans?command=calculateLoanSchedule');
expect(req.request.body.value).toBe(1);
expect(req.request.body.text).toBe('abc');
req.flush({});
});
});
30 changes: 29 additions & 1 deletion src/app/loans/loans.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ export interface WorkingCapitalLoanDiscountUpdateRequest {
providedIn: 'root'
})
export class LoansService {
private static readonly FORBIDDEN_LOAN_PAYLOAD_KEYS: ReadonlySet<string> = new Set([
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if a person requires to disburse a loan to a linked savings account?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@IOhacker Thanks for pointing this out.

The intention here is only to sanitize payloads for the calculateLoanSchedule() flow, which is used for repayment schedule preview/calculation and does not perform actual loan disbursement operations.

Actual disbursement to linked savings accounts goes through separate actions/endpoints such as disburse and disbursetosavings, so those flows remain unaffected by this change.

The issue I observed was specific to the schedule calculation endpoint, where the backend was throwing an NPE when GSIM-linked fields were included in the calculation payload. Since these fields are not required for repayment schedule computation itself, this change removes them only for that calculation request as a temporary defensive workaround until the backend-side validation is addressed.

Please let me know if there's a specific disbursement scenario that uses calculateLoanSchedule() that I may have missed.

'linkAccountId',
'linkAccountOwnerId',
'linkAccountOwnerName',
'linkedSavingsAccount'
]);

private http = inject(HttpClient);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
Expand Down Expand Up @@ -701,8 +708,29 @@ export class LoansService {
return this.http.post('/batches?enclosingTransaction=true', payload);
}

/**
* Calculate repayment schedule preview.
* Sanitize payload to avoid sending GSIM-linked fields that may crash backend.
*/
calculateLoanSchedule(payload: any): Observable<any> {
return this.http.post('/loans?command=calculateLoanSchedule', payload);
const sanitized = this.sanitizeLoanPayload(payload);
return this.http.post('/loans?command=calculateLoanSchedule', sanitized);
}

/**
* Remove potentially unsafe GSIM linkage fields from payload recursively.
* This is a defensive workaround until backend validation is fixed in Fineract.
* Does not mutate the caller's payload; returns a new sanitized copy.
*/
private sanitizeLoanPayload(obj: any): any {
if (obj === null || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map((item) => this.sanitizeLoanPayload(item));
const out: Record<string, any> = {};
for (const key of Object.keys(obj)) {
if (LoansService.FORBIDDEN_LOAN_PAYLOAD_KEYS.has(key)) continue;
out[key] = this.sanitizeLoanPayload(obj[key]);
}
return out;
}

attachLoanOriginator(loanId: string, originatorId: string): Observable<any> {
Expand Down
Loading