diff --git a/packages/wallet/backend/src/app.ts b/packages/wallet/backend/src/app.ts index 94f407975..a5fe07ede 100644 --- a/packages/wallet/backend/src/app.ts +++ b/packages/wallet/backend/src/app.ts @@ -313,6 +313,8 @@ export class App { cardController.getCardsByCustomer ) router.get('/cards/:cardId/details', isAuth, cardController.getCardDetails) + router.put('/cards/:cardId/lock', isAuth, cardController.lock) + router.put('/cards/:cardId/unlock', isAuth, cardController.unlock) router.get( '/cards/:cardId/transactions', isAuth, diff --git a/packages/wallet/backend/src/card/controller.ts b/packages/wallet/backend/src/card/controller.ts index 65fbdc2f8..b8a23f422 100644 --- a/packages/wallet/backend/src/card/controller.ts +++ b/packages/wallet/backend/src/card/controller.ts @@ -5,19 +5,25 @@ import { toSuccessResponse } from '@shared/backend' import { ICardDetailsRequest, ICardDetailsResponse, + ICardLockRequest, ICardResponse, - IGetTransactionsResponse + ICardUnlockRequest, + IGetTransactionsResponse, + getCardTransactionsSchema } from './types' import { validate } from '@/shared/validate' import { getCardsByCustomerSchema, getCardDetailsSchema, - getCardTransactionsSchema + lockCardSchema, + unlockCardSchema } from './validation' export interface ICardController { getCardsByCustomer: Controller getCardDetails: Controller + lock: Controller + unlock: Controller getCardTransactions: Controller } @@ -62,6 +68,39 @@ export class CardController implements ICardController { } } + public lock = async (req: Request, res: Response, next: NextFunction) => { + try { + const { params, query, body } = await validate(lockCardSchema, req) + const { cardId } = params + const { reasonCode } = query + const requestBody: ICardLockRequest = body + + const result = await this.cardService.lock( + cardId, + reasonCode, + requestBody + ) + + res.status(200).json(toSuccessResponse(result)) + } catch (error) { + next(error) + } + } + + public unlock = async (req: Request, res: Response, next: NextFunction) => { + try { + const { params, body } = await validate(unlockCardSchema, req) + const { cardId } = params + const requestBody: ICardUnlockRequest = body + + const result = await this.cardService.unlock(cardId, requestBody) + + res.status(200).json(toSuccessResponse(result)) + } catch (error) { + next(error) + } + } + public getCardTransactions = async ( req: Request, res: Response, diff --git a/packages/wallet/backend/src/card/service.ts b/packages/wallet/backend/src/card/service.ts index 2678b9e24..aeaee193a 100644 --- a/packages/wallet/backend/src/card/service.ts +++ b/packages/wallet/backend/src/card/service.ts @@ -3,9 +3,12 @@ import { GateHubClient } from '../gatehub/client' import { ICardDetailsRequest, ICardDetailsResponse, + ICardLockRequest, ICardResponse, + ICardUnlockRequest, IGetTransactionsResponse } from './types' +import { LockReasonCode } from '@wallet/shared/src' import { NotFound } from '@shared/backend' export class CardService { @@ -50,5 +53,22 @@ export class CardService { if (!walletAddress) { throw new NotFound('Card not found or not associated with the user.') } + + return this.gateHubClient.getCardDetails(requestBody) + } + + async lock( + cardId: string, + reasonCode: LockReasonCode, + requestBody: ICardLockRequest + ): Promise { + return this.gateHubClient.lockCard(cardId, reasonCode, requestBody) + } + + async unlock( + cardId: string, + requestBody: ICardUnlockRequest + ): Promise { + return this.gateHubClient.unlockCard(cardId, requestBody) } } diff --git a/packages/wallet/backend/src/card/types.ts b/packages/wallet/backend/src/card/types.ts index abbc87184..d219e47e2 100644 --- a/packages/wallet/backend/src/card/types.ts +++ b/packages/wallet/backend/src/card/types.ts @@ -95,6 +95,14 @@ export interface ICardProductResponse { cost: string } +export interface ICardLockRequest { + note: string +} + +export interface ICardUnlockRequest { + note: string +} + // Response for fetching card transactions export interface ITransaction { id: number diff --git a/packages/wallet/backend/src/card/validation.ts b/packages/wallet/backend/src/card/validation.ts index d0a498629..1ab620e20 100644 --- a/packages/wallet/backend/src/card/validation.ts +++ b/packages/wallet/backend/src/card/validation.ts @@ -15,6 +15,34 @@ export const getCardDetailsSchema = z.object({ }) }) +export const lockCardSchema = z.object({ + params: z.object({ + cardId: z.string() + }), + query: z.object({ + reasonCode: z.enum([ + 'ClientRequestedLock', + 'LostCard', + 'StolenCard', + 'IssuerRequestGeneral', + 'IssuerRequestFraud', + 'IssuerRequestLegal' + ]) + }), + body: z.object({ + note: z.string() + }) +}) + +export const unlockCardSchema = z.object({ + params: z.object({ + cardId: z.string() + }), + body: z.object({ + note: z.string() + }) +}) + export const getCardTransactionsSchema = z.object({ params: z.object({ cardId: z.string() diff --git a/packages/wallet/backend/src/gatehub/client.ts b/packages/wallet/backend/src/gatehub/client.ts index 30018de51..a6206084b 100644 --- a/packages/wallet/backend/src/gatehub/client.ts +++ b/packages/wallet/backend/src/gatehub/client.ts @@ -30,7 +30,7 @@ import { } from '@/gatehub/consts' import axios, { AxiosError } from 'axios' import { Logger } from 'winston' -import { IFRAME_TYPE } from '@wallet/shared/src' +import { IFRAME_TYPE, LockReasonCode } from '@wallet/shared/src' import { BadRequest } from '@shared/backend' import { ICardDetailsResponse, @@ -40,6 +40,8 @@ import { ICreateCustomerResponse, ICardProductResponse, ICardDetailsRequest, + ICardLockRequest, + ICardUnlockRequest, IGetTransactionsResponse } from '@/card/types' @@ -372,6 +374,40 @@ export class GateHubClient { return cardDetailsResponse } + async lockCard( + cardId: string, + reasonCode: LockReasonCode, + requestBody: ICardLockRequest + ): Promise { + let url = `${this.apiUrl}/v1/cards/${cardId}/lock` + url += `?reasonCode=${encodeURIComponent(reasonCode)}` + + return this.request( + 'PUT', + url, + JSON.stringify(requestBody), + { + cardAppId: this.env.GATEHUB_CARD_APP_ID + } + ) + } + + async unlockCard( + cardId: string, + requestBody: ICardUnlockRequest + ): Promise { + const url = `${this.apiUrl}/v1/cards/${cardId}/unlock` + + return this.request( + 'PUT', + url, + JSON.stringify(requestBody), + { + cardAppId: this.env.GATEHUB_CARD_APP_ID + } + ) + } + async getCardTransactions( cardId: string, pageSize?: number, diff --git a/packages/wallet/backend/tests/cards/controller.test.ts b/packages/wallet/backend/tests/cards/controller.test.ts index 3b888e2b9..04694de65 100644 --- a/packages/wallet/backend/tests/cards/controller.test.ts +++ b/packages/wallet/backend/tests/cards/controller.test.ts @@ -34,7 +34,9 @@ describe('CardController', () => { const mockCardService = { getCardsByCustomer: jest.fn(), - getCardDetails: jest.fn() + getCardDetails: jest.fn(), + lock: jest.fn(), + unlock: jest.fn() } const args = mockLogInRequest().body @@ -85,127 +87,249 @@ describe('CardController', () => { await knex.destroy() }) - it('should get cards by customer successfully', async () => { - const next = jest.fn() - - const mockedCards: ICardResponse[] = [ - { - sourceId: '3dc96e41-279d-4355-921a-e1946e90e1ff', - nameOnCard: 'Jane Doe', - id: 'test-card-id', - accountId: '469E3666F8914020B6B2604F7D4A10F6', - accountSourceId: 'c44e6bc8-d0ef-491e-b374-6d09b6fa6332', - maskedPan: '528700******9830', - status: 'Active', - statusReasonCode: null, - lockLevel: null, - expiryDate: '0929', - customerId: 'customer-id', - customerSourceId: 'a5aba6c7-b8ad-4cfe-98d5-497366a4ee2c', - productCode: 'VMDTKPREB' - } - ] + describe('getCardsByCustomer', () => { + it('should get cards by customer successfully', async () => { + const next = jest.fn() + + const mockedCards: ICardResponse[] = [ + { + sourceId: '3dc96e41-279d-4355-921a-e1946e90e1ff', + nameOnCard: 'Jane Doe', + id: 'test-card-id', + accountId: '469E3666F8914020B6B2604F7D4A10F6', + accountSourceId: 'c44e6bc8-d0ef-491e-b374-6d09b6fa6332', + maskedPan: '528700******9830', + status: 'Active', + statusReasonCode: null, + lockLevel: null, + expiryDate: '0929', + customerId: 'customer-id', + customerSourceId: 'a5aba6c7-b8ad-4cfe-98d5-497366a4ee2c', + productCode: 'VMDTKPREB' + } + ] + + mockCardService.getCardsByCustomer.mockResolvedValue(mockedCards) + + req.params.customerId = 'customer-id' + + await cardController.getCardsByCustomer(req, res, next) + + expect(mockCardService.getCardsByCustomer).toHaveBeenCalledWith( + 'customer-id' + ) + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toEqual({ + success: true, + message: 'SUCCESS', + result: mockedCards + }) + }) - mockCardService.getCardsByCustomer.mockResolvedValue(mockedCards) + it('should return 400 if customerId is missing', async () => { + const next = jest.fn() - req.params.customerId = 'customer-id' + delete req.params.customerId - await cardController.getCardsByCustomer(req, res, next) + await cardController.getCardsByCustomer(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) - expect(mockCardService.getCardsByCustomer).toHaveBeenCalledWith( - 'customer-id' - ) - expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ - success: true, - message: 'SUCCESS', - result: mockedCards + expect(next).toHaveBeenCalled() + const error = next.mock.calls[0][0] + expect(error).toBeInstanceOf(BadRequest) + expect(error.message).toBe('Invalid input') + expect(res.statusCode).toBe(400) }) }) - it('should return 400 if customerId is missing', async () => { - const next = jest.fn() + describe('getCardDetails', () => { + it('should get card details successfully', async () => { + const next = jest.fn() - delete req.params.customerId + req.body = { publicKeyBase64: 'test-public-key' } - await cardController.getCardsByCustomer(req, res, (err) => { - next(err) - res.status(err.statusCode).json({ - success: false, - message: err.message + const mockedCardDetails: ICardDetailsResponse = { + cipher: 'encrypted-card-data' + } + + mockCardService.getCardDetails.mockResolvedValue(mockedCardDetails) + + await cardController.getCardDetails(req, res, next) + + expect(mockCardService.getCardDetails).toHaveBeenCalledWith(userId, { + cardId: 'test-card-id', + publicKeyBase64: 'test-public-key' + }) + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toEqual({ + success: true, + message: 'SUCCESS', + result: mockedCardDetails }) }) - expect(next).toHaveBeenCalled() - const error = next.mock.calls[0][0] - expect(error).toBeInstanceOf(BadRequest) - expect(error.message).toBe('Invalid input') - expect(res.statusCode).toBe(400) - }) + it('should return 400 if cardId is missing', async () => { + const next = jest.fn() + + delete req.params.cardId - it('should get card details successfully', async () => { - const next = jest.fn() + await cardController.getCardsByCustomer(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) - req.body = { publicKeyBase64: 'test-public-key' } + expect(next).toHaveBeenCalled() + const error = next.mock.calls[0][0] + expect(error).toBeInstanceOf(BadRequest) + expect(error.message).toBe('Invalid input') + expect(res.statusCode).toBe(400) + }) - const mockedCardDetails: ICardDetailsResponse = { - cipher: 'encrypted-card-data' - } + it('should return 400 if publicKeyBase64 is missing', async () => { + const next = jest.fn() - mockCardService.getCardDetails.mockResolvedValue(mockedCardDetails) + req.params.cardId = 'test-card-id' + req.body = {} - await cardController.getCardDetails(req, res, next) + await cardController.getCardDetails(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) - expect(mockCardService.getCardDetails).toHaveBeenCalledWith(userId, { - cardId: 'test-card-id', - publicKeyBase64: 'test-public-key' - }) - expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toEqual({ - success: true, - message: 'SUCCESS', - result: mockedCardDetails + expect(next).toHaveBeenCalled() + const error = next.mock.calls[0][0] + expect(error).toBeInstanceOf(BadRequest) + expect(error.message).toBe('Invalid input') + expect(res.statusCode).toBe(400) }) }) - it('should return 400 if cardId is missing', async () => { - const next = jest.fn() + describe('lock', () => { + it('should return 400 if reasonCode is missing', async () => { + const next = jest.fn() + + req.params.cardId = 'test-card-id' + req.body = { note: 'Lost my card' } + delete req.query.reasonCode - delete req.params.cardId + await cardController.lock(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) - await cardController.getCardsByCustomer(req, res, (err) => { - next(err) - res.status(err.statusCode).json({ + expect(next).toHaveBeenCalledWith(expect.any(BadRequest)) + expect(res.statusCode).toBe(400) + expect(res._getJSONData()).toMatchObject({ success: false, - message: err.message + message: 'Invalid input' }) }) - expect(next).toHaveBeenCalled() - const error = next.mock.calls[0][0] - expect(error).toBeInstanceOf(BadRequest) - expect(error.message).toBe('Invalid input') - expect(res.statusCode).toBe(400) - }) + it('should return 400 if reasonCode is invalid', async () => { + const next = jest.fn() - it('should return 400 if publicKeyBase64 is missing', async () => { - const next = jest.fn() + req.params.cardId = 'test-card-id' + req.query.reasonCode = 'InvalidCode' + req.body = { note: 'Lost my card' } - req.params.cardId = 'test-card-id' - req.body = {} + await cardController.lock(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) - await cardController.getCardDetails(req, res, (err) => { - next(err) - res.status(err.statusCode).json({ + expect(next).toHaveBeenCalledWith(expect.any(BadRequest)) + expect(res.statusCode).toBe(400) + expect(res._getJSONData()).toMatchObject({ success: false, - message: err.message + message: 'Invalid input' }) }) - expect(next).toHaveBeenCalled() - const error = next.mock.calls[0][0] - expect(error).toBeInstanceOf(BadRequest) - expect(error.message).toBe('Invalid input') - expect(res.statusCode).toBe(400) + it('should return 400 if note is missing', async () => { + const next = jest.fn() + + req.params.cardId = 'test-card-id' + req.query.reasonCode = 'LostCard' + req.body = {} + + await cardController.lock(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) + + expect(next).toHaveBeenCalledWith(expect.any(BadRequest)) + expect(res.statusCode).toBe(400) + expect(res._getJSONData()).toMatchObject({ + success: false, + message: 'Invalid input' + }) + }) + }) + + describe('unlock', () => { + it('should unlock the card successfully', async () => { + const next = jest.fn() + + req.params.cardId = 'test-card-id' + req.body = { note: 'Found my card' } + + const mockResult = { status: 'unlocked' } + mockCardService.unlock.mockResolvedValue(mockResult) + + await cardController.unlock(req, res, next) + + expect(mockCardService.unlock).toHaveBeenCalledWith('test-card-id', { + note: 'Found my card' + }) + + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toEqual({ + success: true, + message: 'SUCCESS', + result: mockResult + }) + }) + + it('should return 400 if note is missing', async () => { + const next = jest.fn() + + req.params.cardId = 'test-card-id' + req.body = {} + + await cardController.unlock(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) + + expect(next).toHaveBeenCalledWith(expect.any(BadRequest)) + expect(res.statusCode).toBe(400) + expect(res._getJSONData()).toMatchObject({ + success: false, + message: 'Invalid input' + }) + }) }) }) diff --git a/packages/wallet/shared/src/types/card.ts b/packages/wallet/shared/src/types/card.ts new file mode 100644 index 000000000..db620f146 --- /dev/null +++ b/packages/wallet/shared/src/types/card.ts @@ -0,0 +1,7 @@ +export type LockReasonCode = + | 'ClientRequestedLock' + | 'LostCard' + | 'StolenCard' + | 'IssuerRequestGeneral' + | 'IssuerRequestFraud' + | 'IssuerRequestLegal' diff --git a/packages/wallet/shared/src/types/index.ts b/packages/wallet/shared/src/types/index.ts index d8baf80ee..ad25b2eb9 100644 --- a/packages/wallet/shared/src/types/index.ts +++ b/packages/wallet/shared/src/types/index.ts @@ -8,3 +8,4 @@ export * from './grant' export * from './walletAddress' export * from './user' export * from './iframe' +export * from './card'