feat: add transaction, card , and account entities

This commit is contained in:
Abdalhamid Alhamad
2025-07-02 18:42:38 +03:00
parent 4cbbfd8136
commit d3057beb54
29 changed files with 670 additions and 21 deletions

View File

@ -0,0 +1,14 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TransactionService } from '~/card/services/transaction.service';
import { CardTransactionWebhookRequest } from '../dtos/requests';
@Controller('neoleap-webhooks')
@ApiTags('Neoleap Webhooks')
export class NeoLeapWebhooksController {
constructor(private readonly transactionService: TransactionService) {}
@Post('account-transaction')
async handleAccountTransactionWebhook(@Body() body: CardTransactionWebhookRequest) {
await this.transactionService.createCardTransaction(body);
}
}

View File

@ -1,22 +1,39 @@
import { Controller, Get } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ApiTags } from '@nestjs/swagger';
import { CardService } from '~/card/services';
import { ApiDataResponse } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { Customer } from '~/customer/entities';
import { CustomerService } from '~/customer/services';
import { CreateApplicationResponse, InquireApplicationResponse } from '../dtos/response';
import { NeoLeapService } from '../services/neoleap.service';
@Controller('neotest')
@ApiTags('Neoleap Test API , for testing purposes only, will be removed in production')
export class NeoTestController {
constructor(private readonly neoleapService: NeoLeapService, private readonly customerService: CustomerService) {}
constructor(
private readonly neoleapService: NeoLeapService,
private readonly customerService: CustomerService,
private readonly cardService: CardService,
private readonly configService: ConfigService,
) {}
@Get('inquire-application')
@ApiDataResponse(InquireApplicationResponse)
async inquireApplication() {
return this.neoleapService.inquireApplication('1');
const data = await this.neoleapService.inquireApplication('15');
return ResponseFactory.data(data);
}
@Get('create-application')
@ApiDataResponse(CreateApplicationResponse)
async createApplication() {
const customer = await this.customerService.findAnyCustomer();
return this.neoleapService.createApplication(customer as Customer);
const customer = await this.customerService.findCustomerById(
this.configService.get<string>('MOCK_CUSTOMER_ID', '0778c431-f604-4b91-af53-49c33849b5ff'),
);
const data = await this.neoleapService.createApplication(customer as Customer);
await this.cardService.createCard(customer.id, data);
return ResponseFactory.data(data);
}
}

View File

@ -0,0 +1,153 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Type } from 'class-transformer';
import { IsNumber, IsString, ValidateNested } from 'class-validator';
export class CardAcceptorLocationDto {
@Expose()
@IsString()
@ApiProperty()
merchantId!: string;
@Expose()
@IsString()
@ApiProperty()
merchantName!: string;
@Expose()
@IsString()
@ApiProperty()
merchantCountry!: string;
@Expose()
@IsString()
@ApiProperty()
merchantCity!: string;
@Expose()
@IsString()
@ApiProperty()
mcc!: string;
}
export class CardTransactionWebhookRequest {
@Expose({ name: 'InstId' })
@IsString()
@ApiProperty({ name: 'InstId', example: '1100' })
instId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '30829' })
cardId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '1234567890123456' })
transactionId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '277012*****3456' })
cardMaskedNumber!: string;
@Expose()
@IsString()
@ApiProperty({ example: '1234567890123456' })
accountNumber!: string;
@Expose({ name: 'Date' })
@IsString()
@ApiProperty({ name: 'Date', example: '20241112' })
date!: string;
@Expose({ name: 'Time' })
@IsString()
@ApiProperty({ name: 'Time', example: '125250' })
time!: string;
@Expose()
@Type(() => Number)
@IsNumber()
@ApiProperty({ example: '132' })
otb!: number;
@Expose()
@IsString()
@ApiProperty({ example: '0' })
transactionCode!: string;
@Expose()
@IsString()
@ApiProperty({ example: '1' })
messageClass!: string;
@Expose({ name: 'RRN' })
@IsString()
@ApiProperty({ name: 'RRN', example: '431712003306' })
rrn!: string;
@Expose()
@IsString()
@ApiProperty({ example: '3306' })
stan!: string;
@Expose()
@ValidateNested()
@Type(() => CardAcceptorLocationDto)
@ApiProperty({ type: CardAcceptorLocationDto })
cardAcceptorLocation!: CardAcceptorLocationDto;
@Type(() => Number)
@IsNumber()
@ApiProperty({ example: '100.5' })
transactionAmount!: number;
@IsString()
@ApiProperty({ example: '682' })
transactionCurrency!: string;
@Type(() => Number)
@IsNumber()
@ApiProperty({ example: '100.5' })
billingAmount!: number;
@IsString()
@ApiProperty({ example: '682' })
billingCurrency!: string;
@Type(() => Number)
@IsNumber()
@ApiProperty({ example: '100.5' })
settlementAmount!: number;
@IsString()
@ApiProperty({ example: '682' })
settlementCurrency!: string;
@Expose()
@Type(() => Number)
@IsNumber()
@ApiProperty({ example: '20' })
fees!: number;
@Expose()
@Type(() => Number)
@IsNumber()
@ApiProperty({ example: '4.5' })
vatOnFees!: number;
@Expose()
@IsString()
@ApiProperty({ example: '9' })
posEntryMode!: string;
@Expose()
@IsString()
@ApiProperty({ example: '036657' })
authIdResponse!: string;
@Expose({ name: 'POSCDIM' })
@IsString()
@ApiProperty({ name: 'POSCDIM', example: '9' })
posCdim!: string;
}

View File

@ -0,0 +1 @@
export * from './card-transaction-webhook.request.dto';

View File

@ -1,12 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Transform } from 'class-transformer';
import { InquireApplicationResponse } from './inquire-application.response';
export class CreateApplicationResponse extends InquireApplicationResponse {
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier.Id)
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier.Id.toString())
@Expose()
cardId!: number;
@ApiProperty()
cardId!: string;
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier.VPan)
@Expose()
@ApiProperty()
vpan!: string;
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ExpiryDate)
@Expose()
@ApiProperty()
expiryDate!: string;
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.CardStatus)
@Expose()
@ApiProperty()
cardStatus!: string;
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier?.MaskedPan?.split('_')[0])
@Expose()
@ApiProperty()
firstSixDigits!: string;
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier?.MaskedPan?.split('_')[1])
@Expose()
@ApiProperty()
lastFourDigits!: string;
@Transform(({ obj }) => obj.AccountDetailsList?.[0]?.Id.toString())
@Expose()
@ApiProperty()
accountId!: string;
}

View File

@ -1,143 +1,179 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Transform } from 'class-transformer';
export class InquireApplicationResponse {
@Transform(({ obj }) => obj.ApplicationDetails?.ApplicationNumber)
@Expose()
@ApiProperty()
applicationNumber!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ExternalApplicationNumber)
@Expose()
@ApiProperty()
externalApplicationNumber!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ApplicationStatus)
@Expose()
@ApiProperty()
applicationStatus!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Organization)
@Expose()
@ApiProperty()
organization!: number;
@Transform(({ obj }) => obj.ApplicationDetails?.Product)
@Expose()
@ApiProperty()
product!: string;
// this typo is from neoleap, so we keep it as is
@Transform(({ obj }) => obj.ApplicationDetails?.ApplicatonDate)
@Expose()
@ApiProperty()
applicationDate!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ApplicationSource)
@Expose()
@ApiProperty()
applicationSource!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.SalesSource)
@Expose()
@ApiProperty()
salesSource!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.DeliveryMethod)
@Expose()
@ApiProperty()
deliveryMethod!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ProgramCode)
@Expose()
@ApiProperty()
ProgramCode!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Plastic)
@Expose()
@ApiProperty()
plastic!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Design)
@Expose()
@ApiProperty()
design!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ProcessStage)
@Expose()
@ApiProperty()
processStage!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ProcessStageStatus)
@Expose()
@ApiProperty()
processStageStatus!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.EligibilityCheckResult)
@Expose()
@ApiProperty()
eligibilityCheckResult!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.EligibilityCheckDescription)
@Expose()
@ApiProperty()
eligibilityCheckDescription!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Title)
@Expose()
@ApiProperty()
title!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.FirstName)
@Expose()
@ApiProperty()
firstName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.SecondName)
@Expose()
@ApiProperty()
secondName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ThirdName)
@Expose()
@ApiProperty()
thirdName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.LastName)
@Expose()
@ApiProperty()
lastName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.FullName)
@Expose()
@ApiProperty()
fullName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.EmbossName)
@Expose()
@ApiProperty()
embossName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.PlaceOfBirth)
@Expose()
@ApiProperty()
placeOfBirth!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.DateOfBirth)
@Expose()
@ApiProperty()
dateOfBirth!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.LocalizedDateOfBirth)
@Expose()
@ApiProperty()
localizedDateOfBirth!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Age)
@Expose()
@ApiProperty()
age!: number;
@Transform(({ obj }) => obj.ApplicationDetails?.Gender)
@Expose()
@ApiProperty()
gender!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Married)
@Expose()
@ApiProperty()
married!: string;
@Transform(({ obj }) => obj.ApplicationDetails.Nationality)
@Expose()
@ApiProperty()
nationality!: string;
@Transform(({ obj }) => obj.ApplicationDetails.IdType)
@Expose()
@ApiProperty()
idType!: string;
@Transform(({ obj }) => obj.ApplicationDetails.IdNumber)
@Expose()
@ApiProperty()
idNumber!: string;
@Transform(({ obj }) => obj.ApplicationDetails.IdExpiryDate)
@Expose()
@ApiProperty()
idExpiryDate!: string;
@Transform(({ obj }) => obj.ApplicationStatusDetails?.Description)
@Expose()
@ApiProperty()
applicationStatusDescription!: string;
@Transform(({ obj }) => obj.ApplicationStatusDetails?.Canceled)
@Expose()
@ApiProperty()
canceled!: boolean;
}

View File

@ -1,12 +1,14 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { CardModule } from '~/card/card.module';
import { CustomerModule } from '~/customer/customer.module';
import { NeoLeapWebhooksController } from './controllers/neoleap-webhooks.controller';
import { NeoTestController } from './controllers/neotest.controller';
import { NeoLeapService } from './services/neoleap.service';
@Module({
imports: [HttpModule, CustomerModule],
controllers: [NeoTestController],
imports: [HttpModule, CustomerModule, CardModule],
controllers: [NeoTestController, NeoLeapWebhooksController],
providers: [NeoLeapService],
})
export class NeoLeapModule {}

View File

@ -29,6 +29,9 @@ export class NeoLeapService {
async createApplication(customer: Customer) {
const responseKey = 'CreateNewApplicationResponseDetails';
if (customer.cards.length > 0) {
throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD');
}
if (this.useMock) {
return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], {
excludeExtraneousValues: true,
@ -39,7 +42,7 @@ export class NeoLeapService {
CreateNewApplicationRequestDetails: {
ApplicationRequestDetails: {
InstitutionCode: this.institutionCode,
ExternalApplicationNumber: customer.waitingNumber.toString(),
ExternalApplicationNumber: (customer.waitingNumber * 64).toString(),
ApplicationType: '01',
Product: '1101',
ApplicationDate: moment().format('YYYY-MM-DD'),
@ -66,7 +69,7 @@ export class NeoLeapService {
FullName: customer.fullName,
DateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'),
EmbossName: customer.fullName.toUpperCase(), // TODO Enter Emboss Name
IdType: '001',
IdType: '01',
IdNumber: customer.nationalId,
IdExpiryDate: moment(customer.nationalIdExpiry).format('YYYY-MM-DD'),
Title: customer.gender === Gender.MALE ? 'Mr' : 'Ms',
@ -76,7 +79,7 @@ export class NeoLeapService {
},
ApplicationAddress: {
City: customer.city,
Country: CountriesNumericISO[customer.countryOfResidence],
Country: CountriesNumericISO[customer.country],
Region: customer.region,
AddressLine1: `${customer.street} ${customer.building}`,
AddressLine2: customer.neighborhood,
@ -174,7 +177,11 @@ export class NeoLeapService {
return plainToInstance(responseClass, response.data[responseKey], {
excludeExtraneousValues: true,
});
} catch (error) {
} catch (error: any) {
if (error.status === 400) {
console.error('Error sending request to NeoLeap:', error);
throw new BadRequestException(error.response?.data?.ResponseHeader?.ResponseDescription || error.message);
}
console.error('Error sending request to NeoLeap:', error);
throw new InternalServerErrorException('Error communicating with NeoLeap service');
}