diff --git a/docker/dev/.env.example b/docker/dev/.env.example index 85931f17f..d4dddddb5 100644 --- a/docker/dev/.env.example +++ b/docker/dev/.env.example @@ -9,6 +9,7 @@ GATEHUB_GATEWAY_UUID= GATEHUB_VAULT_UUID_EUR= GATEHUB_VAULT_UUID_USD= GATEHUB_SETTLEMENT_WALLET_ADDRESS= +GATEHUB_CARD_APP_ID= # commerce env variables # encoded base 64 private key diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml index 8cdf37cca..d64288cc5 100644 --- a/docker/dev/docker-compose.yml +++ b/docker/dev/docker-compose.yml @@ -55,6 +55,7 @@ services: GATEHUB_VAULT_UUID_EUR: ${GATEHUB_VAULT_UUID_EUR} GATEHUB_VAULT_UUID_USD: ${GATEHUB_VAULT_UUID_USD} GATEHUB_SETTLEMENT_WALLET_ADDRESS: ${GATEHUB_SETTLEMENT_WALLET_ADDRESS} + GATEHUB_CARD_APP_ID: ${GATEHUB_CARD_APP_ID} restart: always networks: - testnet diff --git a/docker/prod/.env.example b/docker/prod/.env.example index cfef6d447..a25cded13 100644 --- a/docker/prod/.env.example +++ b/docker/prod/.env.example @@ -32,6 +32,7 @@ WALLET_BACKEND_GATEHUB_GATEWAY_UUID= WALLET_BACKEND_GATEHUB_VAULT_UUID_EUR= WALLET_BACKEND_GATEHUB_VAULT_UUID_USD= WALLET_BACKEND_GATEHUB_SETTLEMENT_WALLET_ADDRESS= +WALLET_BACKEND_GATEHUB_CARD_APP_ID= # BOUTIQUE BOUTIQUE_BACKEND_PORT= diff --git a/docker/prod/docker-compose.yml b/docker/prod/docker-compose.yml index fec73f297..af6c85eab 100644 --- a/docker/prod/docker-compose.yml +++ b/docker/prod/docker-compose.yml @@ -66,6 +66,7 @@ services: GATEHUB_VAULT_UUID_EUR: ${WALLET_BACKEND_GATEHUB_VAULT_UUID_EUR} GATEHUB_VAULT_UUID_USD: ${WALLET_BACKEND_GATEHUB_VAULT_UUID_USD} GATEHUB_SETTLEMENT_WALLET_ADDRESS: ${WALLET_BACKEND_GATEHUB_SETTLEMENT_WALLET_ADDRESS} + GATEHUB_CARD_APP_ID: ${WALLET_BACKEND_GATEHUB_CARD_APP_ID} networks: - testnet ports: diff --git a/packages/wallet/backend/src/app.ts b/packages/wallet/backend/src/app.ts index e481336be..4298d9250 100644 --- a/packages/wallet/backend/src/app.ts +++ b/packages/wallet/backend/src/app.ts @@ -307,11 +307,7 @@ export class App { ) // Cards - router.get( - '/cards/:cardId/masked-details', - isAuth, - cardController.getMaskedCardDetails - ) + router.get('/:customerId/cards', isAuth, cardController.getCardsByCustomer) router.get('/cards/:cardId/details', isAuth, cardController.getCardDetails) // Return an error for invalid routes diff --git a/packages/wallet/backend/src/card/controller.ts b/packages/wallet/backend/src/card/controller.ts index d56a318d0..559fdc394 100644 --- a/packages/wallet/backend/src/card/controller.ts +++ b/packages/wallet/backend/src/card/controller.ts @@ -2,12 +2,18 @@ import { Request, Response, NextFunction } from 'express' import { Controller, NotFound } from '@shared/backend' import { CardService } from '@/card/service' import { toSuccessResponse } from '@shared/backend' -import { IMaskedCardDetailsResponse } from './types' +import { + ICardDetailsRequest, + ICardDetailsResponse, + ICardResponse +} from './types' import { WalletAddressService } from '@/walletAddress/service' +import { validate } from '@/shared/validate' +import { getCardsByCustomerSchema, getCardDetailsSchema } from './validation' export interface ICardController { - getMaskedCardDetails: Controller - getCardDetails: Controller + getCardsByCustomer: Controller + getCardDetails: Controller } export class CardController implements ICardController { @@ -16,27 +22,17 @@ export class CardController implements ICardController { private walletAddressService: WalletAddressService ) {} - public getMaskedCardDetails = async ( + public getCardsByCustomer = async ( req: Request, res: Response, next: NextFunction ) => { try { - const userId = req.session.user.id - const { cardId } = req.params - - const walletAddress = await this.walletAddressService.getByCardId( - userId, - cardId - ) + const { params } = await validate(getCardsByCustomerSchema, req) + const { customerId } = params - if (!walletAddress) { - throw new NotFound('Card not found or not associated with the user.') - } - - const maskedCardDetails = - await this.cardService.getMaskedCardDetails(cardId) - res.status(200).json(toSuccessResponse(maskedCardDetails)) + const cards = await this.cardService.getCardsByCustomer(customerId) + res.status(200).json(toSuccessResponse(cards)) } catch (error) { next(error) } @@ -49,7 +45,9 @@ export class CardController implements ICardController { ) => { try { const userId = req.session.user.id - const { cardId, publicKeyBase64 } = req.params + const { params, body } = await validate(getCardDetailsSchema, req) + const { cardId } = params + const { publicKeyBase64 } = body const walletAddress = await this.walletAddressService.getByCardId( userId, @@ -60,10 +58,8 @@ export class CardController implements ICardController { throw new NotFound('Card not found or not associated with the user.') } - const cardDetails = await this.cardService.getCardDetails( - cardId, - publicKeyBase64 - ) + const requestBody: ICardDetailsRequest = { cardId, publicKeyBase64 } + const cardDetails = await this.cardService.getCardDetails(requestBody) res.status(200).json(toSuccessResponse(cardDetails)) } catch (error) { next(error) diff --git a/packages/wallet/backend/src/card/service.ts b/packages/wallet/backend/src/card/service.ts index 66a06f310..025945d20 100644 --- a/packages/wallet/backend/src/card/service.ts +++ b/packages/wallet/backend/src/card/service.ts @@ -1,19 +1,20 @@ import { GateHubClient } from '../gatehub/client' -import { ICardDetailsResponse, IMaskedCardDetailsResponse } from './types' +import { + ICardDetailsRequest, + ICardDetailsResponse, + ICardResponse +} from './types' export class CardService { constructor(private gateHubClient: GateHubClient) {} - async getMaskedCardDetails( - cardId: string - ): Promise { - return this.gateHubClient.getMaskedCardDetails(cardId) + async getCardsByCustomer(customerId: string): Promise { + return this.gateHubClient.getCardsByCustomer(customerId) } async getCardDetails( - cardId: string, - publicKeyBase64: string + requestBody: ICardDetailsRequest ): Promise { - return this.gateHubClient.getCardDetails(cardId, publicKeyBase64) + return this.gateHubClient.getCardDetails(requestBody) } } diff --git a/packages/wallet/backend/src/card/types.ts b/packages/wallet/backend/src/card/types.ts index d0d4b6861..f3802e544 100644 --- a/packages/wallet/backend/src/card/types.ts +++ b/packages/wallet/backend/src/card/types.ts @@ -1,6 +1,60 @@ -export interface IMaskedCardDetailsResponse { - sourceId: string | null - nameOnCard: string | null +export interface ICardDetailsRequest { + cardId: string + publicKeyBase64: string +} + +export interface ICardDetailsResponse { + cipher: string | null +} + +export interface ILinksResponse { + token: string | null + links: Array<{ + href: string | null + rel: string | null + method: string | null + }> | null +} + +export interface ICreateCustomerRequest { + emailAddress: string + account: { + productCode: string + } + card: { + productCode: string + } + user: { + firstName: string + lastName: string + mobileNumber?: string + nationalIdentifier?: string + } + identification: { + documents: Array<{ + type: string + file: string // Base64-encoded file content + }> + } + address: { + addressLine1: string + addressLine2?: string + city: string + region?: string + postalCode: string + countryCode: string + } +} + +export interface ICreateCustomerResponse { + customerId: string + accountId: string + cardId: string +} + +export interface ICardResponse { + sourceId: string + nameOnCard: string productCode: string id: string accountId: string @@ -14,13 +68,29 @@ export interface IMaskedCardDetailsResponse { customerSourceId: string } -export interface ICardDetailsResponse {} +export type CardLimitType = + | 'perTransaction' + | 'dailyOverall' + | 'weeklyOverall' + | 'monthlyOverall' + | 'dailyAtm' + | 'dailyEcomm' + | 'monthlyOpenScheme' + | 'nonEUPayments' -export interface ILinksResponse { - token: string | null - links: Array<{ - href: string | null - rel: string | null - method: string | null - }> | null +export interface ICardProductLimit { + type: CardLimitType + currency: string + limit: string + isDisabled: boolean +} + +export interface ICardProductResponse { + cardProductLimits: ICardProductLimit[] + deletedAt: string | null + uuid: string + accountProductCode: string + code: string + name: string + cost: string } diff --git a/packages/wallet/backend/src/card/validation.ts b/packages/wallet/backend/src/card/validation.ts new file mode 100644 index 000000000..d43f84b2d --- /dev/null +++ b/packages/wallet/backend/src/card/validation.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' + +export const getCardsByCustomerSchema = z.object({ + params: z.object({ + customerId: z.string() + }) +}) + +export const getCardDetailsSchema = z.object({ + params: z.object({ + cardId: z.string() + }), + body: z.object({ + publicKeyBase64: z.string() + }) +}) diff --git a/packages/wallet/backend/src/config/env.ts b/packages/wallet/backend/src/config/env.ts index 3aa9013cb..1a9308dc8 100644 --- a/packages/wallet/backend/src/config/env.ts +++ b/packages/wallet/backend/src/config/env.ts @@ -20,6 +20,7 @@ const envSchema = z.object({ GATEHUB_SETTLEMENT_WALLET_ADDRESS: z .string() .default('GATEHUB_SETTLEMENT_WALLET_ADDRESS'), + GATEHUB_CARD_APP_ID: z.string().default('GATEHUB_CARD_APP_ID'), GRAPHQL_ENDPOINT: z.string().url().default('http://localhost:3011/graphql'), AUTH_GRAPHQL_ENDPOINT: z .string() diff --git a/packages/wallet/backend/src/gatehub/client.ts b/packages/wallet/backend/src/gatehub/client.ts index 156a1a0bb..129a54008 100644 --- a/packages/wallet/backend/src/gatehub/client.ts +++ b/packages/wallet/backend/src/gatehub/client.ts @@ -34,8 +34,12 @@ import { IFRAME_TYPE } from '@wallet/shared/src' import { BadRequest } from '@shared/backend' import { ICardDetailsResponse, - IMaskedCardDetailsResponse, - ILinksResponse + ILinksResponse, + ICardResponse, + ICreateCustomerRequest, + ICreateCustomerResponse, + ICardProductResponse, + ICardDetailsRequest } from '@/card/types' export class GateHubClient { @@ -306,26 +310,34 @@ export class GateHubClient { return flatRates } - async getMaskedCardDetails( - cardId: string - ): Promise { - const url = `${this.apiUrl}/cards/${cardId}/card` - - const response = await this.request('GET', url) + // This should be called before creating customers to get the product codes for the card and account + async fetchCardApplicationProducts(): Promise { + const path = `/v1/card-applications/${this.env.GATEHUB_CARD_APP_ID}/card-products` + const response = await this.request('GET', path) return response } + async createCustomer( + request: ICreateCustomerRequest + ): Promise { + const url = `${this.apiUrl}/v1/customers` + return this.request( + 'POST', + url, + JSON.stringify(request) + ) + } + + async getCardsByCustomer(customerId: string): Promise { + const url = `/v1/customers/${customerId}/cards` + return this.request('GET', url) + } + async getCardDetails( - cardId: string, - publicKeyBase64: string + requestBody: ICardDetailsRequest ): Promise { const url = `${this.apiUrl}/token/card-data` - const requestBody = { - cardId, - publicKeyBase64 - } - const response = await this.request( 'POST', url, @@ -337,9 +349,9 @@ export class GateHubClient { throw new Error('Failed to obtain token for card data retrieval') } + // TODO // Will get this from the response const cardDetailsUrl = '' - // TODO const cardDetailsResponse = await this.request( 'GET', cardDetailsUrl @@ -402,6 +414,7 @@ export class GateHubClient { 'x-gatehub-app-id': this.env.GATEHUB_ACCESS_KEY, 'x-gatehub-timestamp': timestamp, 'x-gatehub-signature': this.getSignature(timestamp, method, url, body), + 'x-gatehub-card-app-id': this.env.GATEHUB_CARD_APP_ID, ...(managedUserUuid && { 'x-gatehub-managed-user-uuid': managedUserUuid }) } } diff --git a/packages/wallet/backend/tests/cards/controller.test.ts b/packages/wallet/backend/tests/cards/controller.test.ts index bd567415e..aafe3032d 100644 --- a/packages/wallet/backend/tests/cards/controller.test.ts +++ b/packages/wallet/backend/tests/cards/controller.test.ts @@ -5,8 +5,8 @@ import { MockResponse } from 'node-mocks-http' import { CardController } from '@/card/controller' -import { NotFound } from '@shared/backend' -import { IMaskedCardDetailsResponse, ICardDetailsResponse } from '@/card/types' +import { BadRequest } from '@shared/backend' +import { ICardDetailsResponse, ICardResponse } from '@/card/types' import { AwilixContainer } from 'awilix' import { Cradle } from '@/createContainer' import { createApp, TestApp } from '@/tests/app' @@ -33,7 +33,7 @@ describe('CardController', () => { let userId: string const mockCardService = { - getMaskedCardDetails: jest.fn(), + getCardsByCustomer: jest.fn(), getCardDetails: jest.fn() } @@ -55,7 +55,7 @@ describe('CardController', () => { id: user.id, email: user.email, needsWallet: !user.gateHubUserId, - needsIDProof: !user.kycId + needsIDProof: !user.kycVerified } req.params.cardId = 'test-card-id' @@ -94,77 +94,76 @@ describe('CardController', () => { await knex.destroy() }) - it('should get masked card details successfully', async () => { + it('should get cards by customer successfully', async () => { const next = jest.fn() - const mockedWalletAddress = { - id: 'wallet-address-id', - cardId: 'test-card-id' - } - const mockedMaskedCardDetails: IMaskedCardDetailsResponse = { - sourceId: null, - nameOnCard: 'John Doe', - productCode: 'PROD123', - id: 'card-id', - accountId: 'account-id', - accountSourceId: 'account-source-id', - maskedPan: '**** **** **** 1234', - status: 'Active', - statusReasonCode: null, - lockLevel: null, - expiryDate: '12/25', - customerId: 'customer-id', - customerSourceId: 'customer-source-id' - } - - mockWalletAddressService.getByCardId.mockResolvedValue(mockedWalletAddress) - mockCardService.getMaskedCardDetails.mockResolvedValue( - mockedMaskedCardDetails - ) - - await cardController.getMaskedCardDetails(req, res, next) - - expect(mockWalletAddressService.getByCardId).toHaveBeenCalledWith( - userId, - 'test-card-id' - ) - expect(mockCardService.getMaskedCardDetails).toHaveBeenCalledWith( - 'test-card-id' + 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: mockedMaskedCardDetails + result: mockedCards }) }) - it('should return 404 if card is not associated with user', async () => { + it('should return 400 if customerId is missing', async () => { const next = jest.fn() - mockWalletAddressService.getByCardId.mockResolvedValue(null) + delete req.params.customerId - await cardController.getMaskedCardDetails(req, res, next) + await cardController.getCardsByCustomer(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) - expect(mockWalletAddressService.getByCardId).toHaveBeenCalledWith( - userId, - 'test-card-id' - ) - expect(next).toHaveBeenCalledWith( - new NotFound('Card not found or not associated with the user.') - ) + 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 get card details successfully', async () => { - req.params.publicKeyBase64 = 'test-public-key' - const next = jest.fn() + req.body = { publicKeyBase64: 'test-public-key' } + const mockedWalletAddress = { id: 'wallet-address-id', cardId: 'test-card-id' } - const mockedCardDetails: ICardDetailsResponse = {} + const mockedCardDetails: ICardDetailsResponse = { + cipher: 'encrypted-card-data' + } mockWalletAddressService.getByCardId.mockResolvedValue(mockedWalletAddress) mockCardService.getCardDetails.mockResolvedValue(mockedCardDetails) @@ -175,10 +174,10 @@ describe('CardController', () => { userId, 'test-card-id' ) - expect(mockCardService.getCardDetails).toHaveBeenCalledWith( - 'test-card-id', - 'test-public-key' - ) + expect(mockCardService.getCardDetails).toHaveBeenCalledWith({ + cardId: 'test-card-id', + publicKeyBase64: 'test-public-key' + }) expect(res.statusCode).toBe(200) expect(res._getJSONData()).toEqual({ success: true, @@ -187,21 +186,44 @@ describe('CardController', () => { }) }) - it('should return 404 if card is not associated with user', async () => { - req.params.publicKeyBase64 = 'test-public-key' - + it('should return 400 if cardId is missing', async () => { const next = jest.fn() - mockWalletAddressService.getByCardId.mockResolvedValue(null) + delete req.params.cardId - await cardController.getCardDetails(req, res, next) + await cardController.getCardsByCustomer(req, res, (err) => { + next(err) + res.status(err.statusCode).json({ + success: false, + message: err.message + }) + }) - expect(mockWalletAddressService.getByCardId).toHaveBeenCalledWith( - userId, - 'test-card-id' - ) - expect(next).toHaveBeenCalledWith( - new NotFound('Card not found or not associated with the user.') - ) + 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 publicKeyBase64 is missing', async () => { + const next = jest.fn() + + req.params.cardId = 'test-card-id' + req.body = {} + + await cardController.getCardDetails(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) }) })