diff --git a/src/aggregator/interfaces/bridge-mock.ts b/src/aggregator/interfaces/bridge-mock.ts index 04897639..b0eaed45 100644 --- a/src/aggregator/interfaces/bridge-mock.ts +++ b/src/aggregator/interfaces/bridge-mock.ts @@ -1,11 +1,12 @@ import { + AuthenticationResponse, BridgeAccount, BridgeAccountStatus, BridgeAccountType, + BridgeRefreshStatus, BridgeTransaction, - UserResponse, - AuthenticationResponse, BridgeUserInformation, + UserResponse, } from './bridge.interface'; export const mockAccount: BridgeAccount = { @@ -116,3 +117,11 @@ export const mockPersonalInformation: BridgeUserInformation[] = [ nb_kids: null, // eslint-disable-line no-null/no-null }, ]; + +export const mockRefreshStatus: BridgeRefreshStatus = { + status: 'finished', + refresh_at: new Date().toISOString(), + mfa: null, // eslint-disable-line no-null/no-null + refresh_accounts_count: 0, + total_accounts_count: 0, +}; diff --git a/src/aggregator/interfaces/bridge.interface.ts b/src/aggregator/interfaces/bridge.interface.ts index a146df30..2aa637a1 100644 --- a/src/aggregator/interfaces/bridge.interface.ts +++ b/src/aggregator/interfaces/bridge.interface.ts @@ -193,3 +193,15 @@ export interface BridgeUserInformation { is_owner?: boolean | null; nb_kids?: number | null; } + +/** + * Status of a refresh + * https://docs.bridgeapi.io/reference#get-a-refresh-status + */ +export interface BridgeRefreshStatus { + status: string; + refresh_at: Date | string; + mfa?: Record | null; + refresh_accounts_count?: number | null; + total_accounts_count?: number | null; +} diff --git a/src/aggregator/services/aggregator.service.spec.ts b/src/aggregator/services/aggregator.service.spec.ts index f3dc0159..9f7ace45 100644 --- a/src/aggregator/services/aggregator.service.spec.ts +++ b/src/aggregator/services/aggregator.service.spec.ts @@ -1,11 +1,16 @@ import { CacheModule, HttpModule, HttpStatus } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { createHmac } from 'crypto'; -import { customerMock } from '../../algoan/dto/customer.objects.mock'; - import { AlgoanModule } from '../../algoan/algoan.module'; +import { customerMock } from '../../algoan/dto/customer.objects.mock'; import { AppModule } from '../../app.module'; -import { mockAccount, mockAuthResponse, mockPersonalInformation, mockTransaction } from '../interfaces/bridge-mock'; +import { + mockAccount, + mockAuthResponse, + mockPersonalInformation, + mockRefreshStatus, + mockTransaction, +} from '../interfaces/bridge-mock'; import { AggregatorService } from './aggregator.service'; import { BridgeClient } from './bridge/bridge.client'; @@ -182,6 +187,24 @@ describe('AggregatorService', () => { }); }); + it('should refresh an item', async () => { + const spy = jest.spyOn(client, 'refreshItem').mockReturnValue(Promise.resolve()); + const itemId = 'mockItemId'; + const token = 'mockToken'; + await service.refresh(itemId, token); + + expect(spy).toBeCalledWith(itemId, token, undefined); + }); + + it('should refresh an item', async () => { + const spy = jest.spyOn(client, 'getRefreshStatus').mockReturnValue(Promise.resolve(mockRefreshStatus)); + const itemId = 'mockItemId'; + const token = 'mockToken'; + await service.getRefreshStatus(itemId, token); + + expect(spy).toBeCalledWith(itemId, token, undefined); + }); + it('should get the accounts', async () => { const spy = jest.spyOn(client, 'getAccounts').mockReturnValue(Promise.resolve([mockAccount])); const token = 'token'; diff --git a/src/aggregator/services/aggregator.service.ts b/src/aggregator/services/aggregator.service.ts index d9f6c20f..e1e77cf2 100644 --- a/src/aggregator/services/aggregator.service.ts +++ b/src/aggregator/services/aggregator.service.ts @@ -1,10 +1,10 @@ import { createHmac } from 'crypto'; import { HttpStatus, Injectable } from '@nestjs/common'; import { config } from 'node-config-ts'; - import { AuthenticationResponse, BridgeAccount, + BridgeRefreshStatus, BridgeTransaction, BridgeUserInformation, UserAccount, @@ -18,6 +18,28 @@ import { BridgeClient, ClientConfig } from './bridge/bridge.client'; export class AggregatorService { constructor(private readonly bridgeClient: BridgeClient) {} + /** + * Refresh a connection + * @param id id of the connection + * @param accessToken access token of the connection + */ + public async refresh(id: string | number, accessToken: string, clientConfig?: ClientConfig): Promise { + return this.bridgeClient.refreshItem(id, accessToken, clientConfig); + } + + /** + * Get the status of the refresh of a connection + * @param id id of the connection + * @param accessToken access token of the connection + */ + public async getRefreshStatus( + id: string | number, + accessToken: string, + clientConfig?: ClientConfig, + ): Promise { + return this.bridgeClient.getRefreshStatus(id, accessToken, clientConfig); + } + /** * Create the Bridge Webview url base on the client and it's callbackUrl * diff --git a/src/aggregator/services/bridge/bridge.client.spec.ts b/src/aggregator/services/bridge/bridge.client.spec.ts index 30fff797..ccf06b76 100644 --- a/src/aggregator/services/bridge/bridge.client.spec.ts +++ b/src/aggregator/services/bridge/bridge.client.spec.ts @@ -1,7 +1,18 @@ +import { CacheModule, CACHE_MANAGER, HttpModule, HttpService } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AxiosResponse } from 'axios'; +import { config } from 'node-config-ts'; +import { of } from 'rxjs'; +import { v4 as uuidV4 } from 'uuid'; import { AlgoanModule } from '../../../algoan/algoan.module'; import { AppModule } from '../../../app.module'; import { ConfigModule } from '../../../config/config.module'; -import { mockAuthResponse, mockPersonalInformation, mockUserResponse } from '../../interfaces/bridge-mock'; +import { + mockAuthResponse, + mockPersonalInformation, + mockRefreshStatus, + mockUserResponse, +} from '../../interfaces/bridge-mock'; import { BridgeAccount, BridgeAccountType, @@ -11,12 +22,6 @@ import { ListResponse, } from '../../interfaces/bridge.interface'; import { BridgeClient } from './bridge.client'; -import { CACHE_MANAGER, CacheModule, HttpModule, HttpService } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { AxiosResponse } from 'axios'; -import { config } from 'node-config-ts'; -import { of } from 'rxjs'; -import { v4 as uuidV4 } from 'uuid'; describe('BridgeClient', () => { let service: BridgeClient; @@ -38,6 +43,53 @@ describe('BridgeClient', () => { expect(service).toBeDefined(); }); + it('can refresh an item', async () => { + const result: AxiosResponse = { + data: {}, + status: 202, + statusText: '', + headers: {}, + config: {}, + }; + + const spy = jest.spyOn(httpService, 'post').mockImplementationOnce(() => of(result)); + + await service.refreshItem('mockItemId', 'secret-access-token'); + + expect(spy).toHaveBeenCalledWith('https://sync.bankin.com/v2/items/mockItemId/refresh', { + headers: { + Authorization: 'Bearer secret-access-token', + 'Client-Id': config.bridge.clientId, + 'Client-Secret': config.bridge.clientSecret, + 'Bankin-Version': config.bridge.bankinVersion, + }, + }); + }); + + it('can get the status of a refresh of an item', async () => { + const result: AxiosResponse = { + data: mockRefreshStatus, + status: 200, + statusText: '', + headers: {}, + config: {}, + }; + + const spy = jest.spyOn(httpService, 'get').mockImplementationOnce(() => of(result)); + + const resp = await service.getRefreshStatus('mockItemId', 'secret-access-token'); + expect(resp).toBe(mockRefreshStatus); + + expect(spy).toHaveBeenCalledWith('https://sync.bankin.com/v2/items/mockItemId/refresh/status', { + headers: { + Authorization: 'Bearer secret-access-token', + 'Client-Id': config.bridge.clientId, + 'Client-Secret': config.bridge.clientSecret, + 'Bankin-Version': config.bridge.bankinVersion, + }, + }); + }); + it('can create a user', async () => { const result: AxiosResponse = { data: mockUserResponse, diff --git a/src/aggregator/services/bridge/bridge.client.ts b/src/aggregator/services/bridge/bridge.client.ts index 9128d20c..1517fedb 100644 --- a/src/aggregator/services/bridge/bridge.client.ts +++ b/src/aggregator/services/bridge/bridge.client.ts @@ -1,19 +1,20 @@ -import { CACHE_MANAGER, Inject, HttpService, Injectable, Logger } from '@nestjs/common'; -import { Cache } from 'cache-manager'; -import { config } from 'node-config-ts'; +import { CACHE_MANAGER, HttpService, Inject, Injectable, Logger } from '@nestjs/common'; import { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; +import { Cache } from 'cache-manager'; import { isNil } from 'lodash'; +import { config } from 'node-config-ts'; import { - UserResponse, - UserAccount, AuthenticationResponse, - ConnectItemResponse, - ListResponse, BridgeAccount, - BridgeTransaction, BridgeBank, BridgeCategory, + BridgeRefreshStatus, + BridgeTransaction, BridgeUserInformation, + ConnectItemResponse, + ListResponse, + UserAccount, + UserResponse, } from '../../interfaces/bridge.interface'; /** @@ -116,6 +117,32 @@ export class BridgeClient { return resp.data; } + /** + * Refresh an item + */ + public async refreshItem(id: string | number, accessToken: string, clientConfig?: ClientConfig): Promise { + const url: string = `${config.bridge.baseUrl}/v2/items/${id}/refresh`; + await this.httpService + .post(url, { headers: { Authorization: `Bearer ${accessToken}`, ...BridgeClient.getHeaders(clientConfig) } }) + .toPromise(); + } + + /** + * Get status of a refresh + */ + public async getRefreshStatus( + id: string | number, + accessToken: string, + clientConfig?: ClientConfig, + ): Promise { + const url: string = `${config.bridge.baseUrl}/v2/items/${id}/refresh/status`; + const resp: AxiosResponse = await this.httpService + .get(url, { headers: { Authorization: `Bearer ${accessToken}`, ...BridgeClient.getHeaders(clientConfig) } }) + .toPromise(); + + return resp.data; + } + /** * Get a bridge user's accounts */ diff --git a/src/hooks/services/hooks.service.spec.ts b/src/hooks/services/hooks.service.spec.ts index 08074b7a..f29ef534 100644 --- a/src/hooks/services/hooks.service.spec.ts +++ b/src/hooks/services/hooks.service.spec.ts @@ -12,9 +12,13 @@ import { import { ContextIdFactory } from '@nestjs/core'; import { Test, TestingModule } from '@nestjs/testing'; import { config } from 'node-config-ts'; - import { AggregatorModule } from '../../aggregator/aggregator.module'; -import { mockAccount, mockPersonalInformation, mockTransaction } from '../../aggregator/interfaces/bridge-mock'; +import { + mockAccount, + mockPersonalInformation, + mockRefreshStatus, + mockTransaction, +} from '../../aggregator/interfaces/bridge-mock'; import { AggregatorService } from '../../aggregator/services/aggregator.service'; import { AlgoanModule } from '../../algoan/algoan.module'; import { analysisMock } from '../../algoan/dto/analysis.objects.mock'; @@ -24,8 +28,7 @@ import { AlgoanCustomerService } from '../../algoan/services/algoan-customer.ser import { AlgoanHttpService } from '../../algoan/services/algoan-http.service'; import { AlgoanService } from '../../algoan/services/algoan.service'; import { AppModule } from '../../app.module'; -import { CONFIG } from '../../config/config.module'; -import { ConfigModule } from '../../config/config.module'; +import { CONFIG, ConfigModule } from '../../config/config.module'; import { AggregatorLinkRequiredDTO } from '../dto/aggregator-link-required.dto'; import { EventDTO } from '../dto/event.dto'; import { HooksService } from './hooks.service'; @@ -317,4 +320,174 @@ describe('HooksService', () => { mockServiceAccountConfig, ); }); + + it('refresh when userId is defined and synchronizes the accounts on bank details required', async () => { + const algoanAuthenticateSpy = jest.spyOn(algoanHttpService, 'authenticate').mockReturnValue(); + const getCustomerSpy = jest.spyOn(algoanCustomerService, 'getCustomerById').mockResolvedValue({ + ...customerMock, + aggregationDetails: { + ...customerMock, + userId: 'mockItemId', + }, + }); + const updateAnalysisSpy = jest.spyOn(algoanAnalysisService, 'updateAnalysis').mockResolvedValue(analysisMock); + mockServiceAccount.config = mockServiceAccountConfig; + const accessTokenSpy = jest.spyOn(aggregatorService, 'getAccessToken').mockResolvedValue({ + access_token: 'mockPermToken', + expires_at: '323423423423', + user: { email: 'test@test.com', uuid: 'rrr', resource_type: 's', resource_uri: '/..' }, + }); + const refreshSpy = jest.spyOn(aggregatorService, 'refresh').mockResolvedValue(); + const refreshStatusSpy = jest + .spyOn(aggregatorService, 'getRefreshStatus') + .mockResolvedValueOnce({ ...mockRefreshStatus, status: 'in progress' }) + .mockResolvedValue(mockRefreshStatus); + const accountSpy = jest + .spyOn(aggregatorService, 'getAccounts') + .mockResolvedValue([mockAccount, { ...mockAccount, id: 0 }]); + const userInfoSpy = jest + .spyOn(aggregatorService, 'getUserPersonalInformation') + .mockResolvedValue(mockPersonalInformation); + const date = new Date().toISOString(); + const transactionSpy = jest + .spyOn(aggregatorService, 'getTransactions') + .mockResolvedValueOnce([ + { ...mockTransaction, date, account: { ...mockTransaction.account, id: mockAccount.id } }, + ]) + .mockResolvedValue([{ ...mockTransaction, account: { ...mockTransaction.account, id: mockAccount.id } }]); + const resourceNameSpy = jest.spyOn(aggregatorService, 'getResourceName').mockResolvedValue('mockResourceName'); + const deleteUserSpy = jest.spyOn(aggregatorService, 'deleteUser').mockResolvedValue(); + + const mockEventPayload = { + customerId: customerMock.id, + analysisId: analysisMock.id, + temporaryCode: 'mockTemporaryToken', + }; + + await hooksService.handleBankDetailsRequiredEvent(mockServiceAccount, mockEventPayload); + + expect(algoanAuthenticateSpy).toBeCalledWith(mockServiceAccount.clientId, mockServiceAccount.clientSecret); + expect(getCustomerSpy).toBeCalledWith(mockEventPayload.customerId); + expect(refreshSpy).toBeCalledWith('mockItemId', 'mockPermToken', mockServiceAccountConfig); + expect(refreshStatusSpy).toBeCalledTimes(2); + expect(refreshStatusSpy).toBeCalledWith('mockItemId', 'mockPermToken', mockServiceAccountConfig); + expect(updateAnalysisSpy).toBeCalledWith(customerMock.id, mockEventPayload.analysisId, { + accounts: [ + { + aggregator: { + id: '1234', + }, + balance: 100, + balanceDate: '2019-04-06T13:53:12.000Z', + bank: { + id: '6', + name: 'mockResourceName', + }, + bic: undefined, + currency: 'USD', + details: { + loan: { + amount: 140200, + endDate: '2026-12-30T23:00:00.000Z', + interestRate: 1.25, + payment: 1000, + remainingCapital: 100000, + startDate: '2013-01-09T23:00:00.000Z', + type: 'OTHER', + }, + savings: undefined, + }, + iban: 'mockIban', + name: 'mockBridgeAccountName', + owners: [ + { + name: ' DUPONT', + }, + ], + type: 'CREDIT_CARD', + usage: 'PERSONAL', + transactions: [ + { + aggregator: { + category: 'mockResourceName', + id: '23', + }, + amount: 30, + currency: 'USD', + dates: { + bookedAt: undefined, + debitedAt: '2019-04-06T13:53:12.000Z', + }, + description: 'mockRawDescription', + isComing: false, + }, + { + aggregator: { + category: 'mockResourceName', + id: '23', + }, + amount: 30, + currency: 'USD', + dates: { + bookedAt: undefined, + debitedAt: date, + }, + description: 'mockRawDescription', + isComing: false, + }, + ], + }, + { + aggregator: { + id: '0', + }, + balance: 100, + balanceDate: '2019-04-06T13:53:12.000Z', + bank: { + id: '6', + name: 'mockResourceName', + }, + bic: undefined, + currency: 'USD', + details: { + loan: { + amount: 140200, + endDate: '2026-12-30T23:00:00.000Z', + interestRate: 1.25, + payment: 1000, + remainingCapital: 100000, + startDate: '2013-01-09T23:00:00.000Z', + type: 'OTHER', + }, + savings: undefined, + }, + iban: 'mockIban', + name: 'mockBridgeAccountName', + owners: [ + { + name: ' DUPONT', + }, + ], + type: 'CREDIT_CARD', + usage: 'PERSONAL', + }, + ], + }); + + expect(accessTokenSpy).toBeCalledWith(customerMock.id, mockServiceAccountConfig); + expect(accountSpy).toBeCalledWith('mockPermToken', mockServiceAccountConfig); + expect(userInfoSpy).toBeCalledWith('mockPermToken', mockServiceAccountConfig); + expect(resourceNameSpy).toBeCalledTimes(4); + expect(transactionSpy).toBeCalledTimes(2); + expect(transactionSpy).toBeCalledWith('mockPermToken', undefined, mockServiceAccountConfig); + expect(deleteUserSpy).toHaveBeenNthCalledWith( + 1, + { + bridgeUserId: 'rrr', + id: customerMock.id, + accessToken: 'mockPermToken', + }, + mockServiceAccountConfig, + ); + }); }); diff --git a/src/hooks/services/hooks.service.ts b/src/hooks/services/hooks.service.ts index 9b0d6c6f..df8fbd59 100644 --- a/src/hooks/services/hooks.service.ts +++ b/src/hooks/services/hooks.service.ts @@ -4,10 +4,10 @@ import * as delay from 'delay'; import { isEmpty } from 'lodash'; import * as moment from 'moment'; import { Config } from 'node-config-ts'; - import { AuthenticationResponse, BridgeAccount, + BridgeRefreshStatus, BridgeTransaction, BridgeUserInformation, } from '../../aggregator/interfaces/bridge.interface'; @@ -177,6 +177,8 @@ export class HooksService { serviceAccount: ServiceAccount, payload: BanksDetailsRequiredDTO, ): Promise { + const saConfig: ClientConfig = serviceAccount.config as ClientConfig; + // Authenticate to algoan this.algoanHttpService.authenticate(serviceAccount.clientId, serviceAccount.clientSecret); @@ -185,18 +187,29 @@ export class HooksService { this.logger.debug({ customer, serviceAccount }, `Found Customer with id ${customer.id}`); // Retrieves an access token from Bridge to access to the user accounts - const authenticationResponse: AuthenticationResponse = await this.aggregator.getAccessToken( - customer.id, - serviceAccount.config as ClientConfig, - ); + const authenticationResponse: AuthenticationResponse = await this.aggregator.getAccessToken(customer.id, saConfig); const accessToken: string = authenticationResponse.access_token; const bridgeUserId: string = authenticationResponse.user.uuid; + if (customer.aggregationDetails.userId !== undefined) { + // Init refresh + await this.aggregator.refresh(customer.aggregationDetails.userId, accessToken, saConfig); + + // Wait until refresh is finished + let refresh: BridgeRefreshStatus; + const timeoutRefresh: moment.Moment = moment().add(this.config.bridge.synchronizationTimeout); + + do { + refresh = await this.aggregator.getRefreshStatus(customer.aggregationDetails.userId, accessToken, saConfig); + } while ( + refresh?.status !== 'finished' && + moment().isBefore(timeoutRefresh) && + (await delay(this.config.bridge.synchronizationWaitingTime, { value: true })) + ); + } + // Retrieves Bridge banks accounts - const accounts: BridgeAccount[] = await this.aggregator.getAccounts( - accessToken, - serviceAccount.config as ClientConfig, - ); + const accounts: BridgeAccount[] = await this.aggregator.getAccounts(accessToken, saConfig); this.logger.debug({ message: `Bridge accounts retrieved for Customer "${customer.id}"`, accounts, @@ -205,7 +218,7 @@ export class HooksService { // Get personal information let userInfo: BridgeUserInformation[] = []; try { - userInfo = await this.aggregator.getUserPersonalInformation(accessToken, serviceAccount.config as ClientConfig); + userInfo = await this.aggregator.getUserPersonalInformation(accessToken, saConfig); } catch (err) { this.logger.warn({ message: `Unable to get user personal information`, error: err }); } @@ -215,7 +228,7 @@ export class HooksService { userInfo, accessToken, this.aggregator, - serviceAccount.config as ClientConfig, + saConfig, ); // Retrieves Bridge transactions @@ -223,13 +236,13 @@ export class HooksService { let lastUpdatedAt: string | undefined; let transactions: BridgeTransaction[] = []; /* eslint-disable no-magic-numbers */ - const nbOfMonths: number = (serviceAccount.config as ClientConfig).nbOfMonths ?? 3; + const nbOfMonths: number = saConfig.nbOfMonths ?? 3; do { const fetchedTransactions: BridgeTransaction[] = await this.aggregator.getTransactions( accessToken, lastUpdatedAt, - serviceAccount.config as ClientConfig, + saConfig, ); transactions = transactions.concat(fetchedTransactions); @@ -251,7 +264,7 @@ export class HooksService { ), accessToken, this.aggregator, - serviceAccount.config as ClientConfig, + saConfig, ); if (!isEmpty(algoanTransactions)) { account.transactions = algoanTransactions; @@ -269,7 +282,7 @@ export class HooksService { id: customer.id, accessToken, }; - await this.aggregator.deleteUser(user, serviceAccount.config as ClientConfig); + await this.aggregator.deleteUser(user, saConfig); return; }