Skip to content

Commit

Permalink
feat(wallet/backend): add permanently block card endpoint (#1662)
Browse files Browse the repository at this point in the history
* Add permanently block card endpoint

* Prettier

* Add controller tests

* Rearrange functions

* Ensure WA exists for lock and unlock calls

* Fix backend tests
  • Loading branch information
sanducb authored Oct 2, 2024
1 parent c784e41 commit 314bc80
Show file tree
Hide file tree
Showing 7 changed files with 189 additions and 41 deletions.
5 changes: 5 additions & 0 deletions packages/wallet/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,11 @@ export class App {
)
router.get('/cards/:cardId/pin', isAuth, cardController.getPin)
router.post('/cards/:cardId/change-pin', isAuth, cardController.changePin)
router.put(
'/cards/:cardId/block',
isAuth,
cardController.permanentlyBlockCard
)

// Return an error for invalid routes
router.use('*', (req: Request, res: CustomResponse) => {
Expand Down
31 changes: 29 additions & 2 deletions packages/wallet/backend/src/card/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import {
lockCardSchema,
unlockCardSchema,
getCardTransactionsSchema,
changePinSchema
changePinSchema,
permanentlyBlockCardSchema
} from './validation'

export interface ICardController {
Expand All @@ -28,6 +29,7 @@ export interface ICardController {
changePin: Controller<void>
lock: Controller<ICardResponse>
unlock: Controller<ICardResponse>
permanentlyBlockCard: Controller<ICardResponse>
}

export class CardController implements ICardController {
Expand Down Expand Up @@ -130,12 +132,14 @@ export class CardController implements ICardController {

public lock = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.session.user.id
const { params, query, body } = await validate(lockCardSchema, req)
const { cardId } = params
const { reasonCode } = query
const requestBody: ICardLockRequest = body

const result = await this.cardService.lock(
userId,
cardId,
reasonCode,
requestBody
Expand All @@ -149,15 +153,38 @@ export class CardController implements ICardController {

public unlock = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.session.user.id
const { params, body } = await validate(unlockCardSchema, req)
const { cardId } = params
const requestBody: ICardUnlockRequest = body

const result = await this.cardService.unlock(cardId, requestBody)
const result = await this.cardService.unlock(userId, cardId, requestBody)

res.status(200).json(toSuccessResponse(result))
} catch (error) {
next(error)
}
}

public permanentlyBlockCard = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
const userId = req.session.user.id
const { params, query } = await validate(permanentlyBlockCardSchema, req)
const { cardId } = params
const { reasonCode } = query

const result = await this.cardService.permanentlyBlockCard(
userId,
cardId,
reasonCode
)
res.status(200).json(toSuccessResponse(result))
} catch (error) {
next(error)
}
}
}
18 changes: 17 additions & 1 deletion packages/wallet/backend/src/card/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { IGetTransactionsResponse } from '@wallet/shared/src'
import { LockReasonCode } from '@wallet/shared/src'
import { NotFound } from '@shared/backend'
import { BlockReasonCode } from '@wallet/shared/src'

export class CardService {
constructor(
Expand All @@ -27,7 +28,6 @@ export class CardService {
): Promise<ICardDetailsResponse> {
const { cardId } = requestBody
await this.ensureWalletAddressExists(userId, cardId)
await this.ensureWalletAddressExists(userId, cardId)

return this.gateHubClient.getCardDetails(requestBody)
}
Expand Down Expand Up @@ -64,20 +64,36 @@ export class CardService {
}

async lock(
userId: string,
cardId: string,
reasonCode: LockReasonCode,
requestBody: ICardLockRequest
): Promise<ICardResponse> {
await this.ensureWalletAddressExists(userId, cardId)

return this.gateHubClient.lockCard(cardId, reasonCode, requestBody)
}

async unlock(
userId: string,
cardId: string,
requestBody: ICardUnlockRequest
): Promise<ICardResponse> {
await this.ensureWalletAddressExists(userId, cardId)

return this.gateHubClient.unlockCard(cardId, requestBody)
}

async permanentlyBlockCard(
userId: string,
cardId: string,
reasonCode: BlockReasonCode
): Promise<ICardResponse> {
await this.ensureWalletAddressExists(userId, cardId)

return this.gateHubClient.permanentlyBlockCard(cardId, reasonCode)
}

private async ensureWalletAddressExists(
userId: string,
cardId: string
Expand Down
20 changes: 20 additions & 0 deletions packages/wallet/backend/src/card/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,23 @@ export const changePinSchema = z.object({
cypher: z.string()
})
})

export const permanentlyBlockCardSchema = z.object({
params: z.object({
cardId: z.string()
}),
query: z.object({
reasonCode: z.enum([
'LostCard',
'StolenCard',
'IssuerRequestGeneral',
'IssuerRequestFraud',
'IssuerRequestLegal',
'IssuerRequestIncorrectOpening',
'CardDamagedOrNotWorking',
'UserRequest',
'IssuerRequestCustomerDeceased',
'ProductDoesNotRenew'
])
})
})
81 changes: 47 additions & 34 deletions packages/wallet/backend/src/gatehub/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import {
ICardLockRequest,
ICardUnlockRequest
} from '@/card/types'
import { BlockReasonCode } from '@wallet/shared/src'

export class GateHubClient {
private clientIds = SANDBOX_CLIENT_IDS
private mainUrl = 'sandbox.gatehub.net'
Expand Down Expand Up @@ -403,40 +405,6 @@ export class GateHubClient {
return this.request<IGetTransactionsResponse>('GET', url)
}

async lockCard(
cardId: string,
reasonCode: LockReasonCode,
requestBody: ICardLockRequest
): Promise<ICardResponse> {
let url = `${this.apiUrl}/v1/cards/${cardId}/lock`
url += `?reasonCode=${encodeURIComponent(reasonCode)}`

return this.request<ICardResponse>(
'PUT',
url,
JSON.stringify(requestBody),
{
cardAppId: this.env.GATEHUB_CARD_APP_ID
}
)
}

async unlockCard(
cardId: string,
requestBody: ICardUnlockRequest
): Promise<ICardResponse> {
const url = `${this.apiUrl}/v1/cards/${cardId}/unlock`

return this.request<ICardResponse>(
'PUT',
url,
JSON.stringify(requestBody),
{
cardAppId: this.env.GATEHUB_CARD_APP_ID
}
)
}

async getPin(
requestBody: ICardDetailsRequest
): Promise<ICardDetailsResponse> {
Expand Down Expand Up @@ -501,6 +469,51 @@ export class GateHubClient {
)
}

async lockCard(
cardId: string,
reasonCode: LockReasonCode,
requestBody: ICardLockRequest
): Promise<ICardResponse> {
let url = `${this.apiUrl}/v1/cards/${cardId}/lock`
url += `?reasonCode=${encodeURIComponent(reasonCode)}`

return this.request<ICardResponse>(
'PUT',
url,
JSON.stringify(requestBody),
{
cardAppId: this.env.GATEHUB_CARD_APP_ID
}
)
}

async unlockCard(
cardId: string,
requestBody: ICardUnlockRequest
): Promise<ICardResponse> {
const url = `${this.apiUrl}/v1/cards/${cardId}/unlock`

return this.request<ICardResponse>(
'PUT',
url,
JSON.stringify(requestBody),
{
cardAppId: this.env.GATEHUB_CARD_APP_ID
}
)
}

async permanentlyBlockCard(
cardId: string,
reasonCode: BlockReasonCode
): Promise<ICardResponse> {
let url = `${this.apiUrl}/v1/cards/${cardId}/block`

url += `?reasonCode=${encodeURIComponent(reasonCode)}`

return this.request<ICardResponse>('PUT', url)
}

private async request<T>(
method: HTTP_METHODS,
url: string,
Expand Down
63 changes: 59 additions & 4 deletions packages/wallet/backend/tests/cards/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ describe('CardController', () => {
lock: jest.fn(),
unlock: jest.fn(),
getPin: jest.fn(),
changePin: jest.fn()
changePin: jest.fn(),
permanentlyBlockCard: jest.fn()
}

const args = mockLogInRequest().body
Expand Down Expand Up @@ -337,6 +338,7 @@ describe('CardController', () => {
await cardController.lock(req, res, next)

expect(mockCardService.lock).toHaveBeenCalledWith(
userId,
'test-card-id',
'LostCard',
{
Expand Down Expand Up @@ -434,9 +436,13 @@ describe('CardController', () => {

await cardController.unlock(req, res, next)

expect(mockCardService.unlock).toHaveBeenCalledWith('test-card-id', {
note: 'Found my card'
})
expect(mockCardService.unlock).toHaveBeenCalledWith(
userId,
'test-card-id',
{
note: 'Found my card'
}
)

expect(res.statusCode).toBe(200)
expect(res._getJSONData()).toEqual({
Expand Down Expand Up @@ -603,4 +609,53 @@ describe('CardController', () => {
expect(res.statusCode).toBe(400)
})
})

describe('permanentlyBlockCard', () => {
it('should get block card successfully', async () => {
const next = jest.fn()

mockCardService.permanentlyBlockCard.mockResolvedValue({})

req.params = { cardId: 'test-card-id' }
req.query = { reasonCode: 'StolenCard' }

await cardController.permanentlyBlockCard(req, res, next)

expect(mockCardService.permanentlyBlockCard).toHaveBeenCalledWith(
userId,
'test-card-id',
'StolenCard'
)
expect(res.statusCode).toBe(200)
expect(res._getJSONData()).toEqual({
success: true,
message: 'SUCCESS',
result: {}
})
})
it('should return 400 if reasonCode is invalid', async () => {
const next = jest.fn()

req.params = { cardId: 'test-card-id' }
req.query = { reasonCode: 'InvalidCode' }

await cardController.permanentlyBlockCard(req, res, (err) => {
next(err)
res.status(err.statusCode).json({
success: false,
message: err.message
})
})

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)
expect(res._getJSONData()).toEqual({
success: false,
message: 'Invalid input'
})
})
})
})
12 changes: 12 additions & 0 deletions packages/wallet/shared/src/types/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ export type LockReasonCode =
| 'IssuerRequestFraud'
| 'IssuerRequestLegal'

export type BlockReasonCode =
| 'LostCard'
| 'StolenCard'
| 'IssuerRequestGeneral'
| 'IssuerRequestFraud'
| 'IssuerRequestLegal'
| 'IssuerRequestIncorrectOpening'
| 'CardDamagedOrNotWorking'
| 'UserRequest'
| 'IssuerRequestCustomerDeceased'
| 'ProductDoesNotRenew'

// Response for fetching card transactions
export interface ITransaction {
id: number
Expand Down

0 comments on commit 314bc80

Please sign in to comment.