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

@ -1,8 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Card } from './entities';
import { Account } from './entities/account.entity';
import { Transaction } from './entities/transaction.entity';
import { CardRepository } from './repositories';
import { AccountRepository } from './repositories/account.repository';
import { TransactionRepository } from './repositories/transaction.repository';
import { CardService } from './services';
import { AccountService } from './services/account.service';
import { TransactionService } from './services/transaction.service';
@Module({
imports: [TypeOrmModule.forFeature([Card])],
imports: [TypeOrmModule.forFeature([Card, Account, Transaction])],
providers: [
CardService,
CardRepository,
TransactionService,
TransactionRepository,
AccountService,
AccountRepository,
],
exports: [CardService, TransactionService],
})
export class CardModule {}

View File

@ -0,0 +1,31 @@
import { Column, CreateDateColumn, Entity, Index, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { Card } from './card.entity';
import { Transaction } from './transaction.entity';
@Entity('accounts')
export class Account {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column('varchar', { length: 255, nullable: false, unique: true, name: 'account_reference' })
@Index({ unique: true })
accountReference!: string;
@Column('varchar', { length: 255, nullable: false, name: 'currency' })
currency!: string;
@Column('decimal', { precision: 10, scale: 2, default: 0.0, name: 'balance' })
balance!: number;
@OneToMany(() => Card, (card) => card.account, { cascade: true })
cards!: Card[];
@OneToMany(() => Transaction, (transaction) => transaction.account, { cascade: true })
transactions!: Transaction[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' })
updatedAt!: Date;
}

View File

@ -5,13 +5,16 @@ import {
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Customer } from '~/customer/entities';
import { CardColors, CardIssuers, CardScheme, CardStatus, CustomerType } from '../enums';
import { Account } from './account.entity';
import { Transaction } from './transaction.entity';
@Entity()
@Entity('cards')
export class Card {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ -20,14 +23,14 @@ export class Card {
@Column({ name: 'card_reference', nullable: false, type: 'varchar' })
cardReference!: string;
@Column({ length: 5, name: 'first_five_digits', nullable: false, type: 'varchar' })
firstFiveDigits!: string;
@Column({ length: 6, name: 'first_six_digits', nullable: false, type: 'varchar' })
firstSixDigits!: string;
@Column({ length: 4, name: 'last_four_digits', nullable: false, type: 'varchar' })
lastFourDigits!: string;
@Column({ type: 'date', nullable: false })
expiry!: Date;
@Column({ type: 'varchar', nullable: false })
expiry!: string;
@Column({ type: 'varchar', nullable: false, name: 'customer_type' })
customerType!: CustomerType;
@ -50,6 +53,9 @@ export class Card {
@Column({ type: 'uuid', name: 'parent_id', nullable: true })
parentId?: string;
@Column({ type: 'uuid', name: 'account_id', nullable: false })
accountId!: string;
@ManyToOne(() => Customer, (customer) => customer.childCards)
@JoinColumn({ name: 'parent_id' })
parentCustomer?: Customer;
@ -58,6 +64,13 @@ export class Card {
@JoinColumn({ name: 'customer_id' })
customer!: Customer;
@ManyToOne(() => Account, (account) => account.cards, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'account_id' })
account!: Account;
@OneToMany(() => Transaction, (transaction) => transaction.card, { cascade: true })
transactions!: Transaction[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date;

View File

@ -0,0 +1,62 @@
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { TransactionType } from '../enums';
import { Account } from './account.entity';
import { Card } from './card.entity';
@Entity('transactions')
export class Transaction {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'card_reference', nullable: true, type: 'varchar' })
cardReference!: string;
@Column({ name: 'transaction_type', type: 'varchar', default: TransactionType.EXTERNAL })
transactionType!: TransactionType;
@Column({ name: 'account_reference', nullable: true, type: 'varchar' })
accountReference!: string;
@Column({ name: 'transaction_id', unique: true, nullable: true, type: 'varchar' })
transactionId!: string;
@Column({ name: 'card_masked_number', nullable: true, type: 'varchar' })
cardMaskedNumber!: string;
@Column({ type: 'timestamp with time zone', name: 'transaction_date', nullable: true })
transactionDate!: Date;
@Column({ name: 'rrn', nullable: true, type: 'varchar' })
rrn!: string;
@Column({ type: 'decimal', precision: 12, scale: 2, name: 'transaction_amount' })
transactionAmount!: number;
@Column({ type: 'varchar', name: 'transaction_currency' })
transactionCurrency!: string;
@Column({ type: 'decimal', name: 'billing_amount', precision: 12, scale: 2 })
billingAmount!: number;
@Column({ type: 'decimal', name: 'settlement_amount', precision: 12, scale: 2 })
settlementAmount!: number;
@Column({ type: 'decimal', name: 'fees', precision: 12, scale: 2 })
fees!: number;
@Column({ name: 'card_id', type: 'uuid', nullable: true })
cardId!: string;
@Column({ name: 'account_id', type: 'uuid', nullable: true })
accountId!: string;
@ManyToOne(() => Card, (card) => card.transactions, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'card_id' })
card!: Card;
@ManyToOne(() => Account, (account) => account.transactions, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'account_id' })
account!: Account;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date;
}

View File

@ -3,3 +3,4 @@ export * from './card-issuers.enum';
export * from './card-scheme.enum';
export * from './card-status.enum';
export * from './customer-type.enum';
export * from './transaction-type.enum';

View File

@ -0,0 +1,4 @@
export enum TransactionType {
INTERNAL = 'INTERNAL',
EXTERNAL = 'EXTERNAL',
}

View File

@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Account } from '../entities/account.entity';
@Injectable()
export class AccountRepository {
constructor(@InjectRepository(Account) private readonly accountRepository: Repository<Account>) {}
createAccount(accountId: string): Promise<Account> {
return this.accountRepository.save(
this.accountRepository.create({
accountReference: accountId,
balance: 0,
currency: '682',
}),
);
}
}

View File

@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response';
import { Card } from '../entities';
import { CardColors, CardIssuers, CardScheme, CardStatus, CustomerType } from '../enums';
@Injectable()
export class CardRepository {
constructor(@InjectRepository(Card) private readonly cardRepository: Repository<Card>) {}
createCard(customerId: string, accountId: string, card: CreateApplicationResponse): Promise<Card> {
return this.cardRepository.save(
this.cardRepository.create({
customerId: customerId,
expiry: card.expiryDate,
cardReference: card.cardId,
customerType: CustomerType.PARENT,
firstSixDigits: card.firstSixDigits,
lastFourDigits: card.lastFourDigits,
color: CardColors.BLUE,
status: CardStatus.ACTIVE,
scheme: CardScheme.VISA,
issuer: CardIssuers.NEOLEAP,
accountId: accountId,
}),
);
}
getCardById(id: string): Promise<Card | null> {
return this.cardRepository.findOne({ where: { id } });
}
getCardByReferenceNumber(referenceNumber: string): Promise<Card | null> {
return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] });
}
}

View File

@ -0,0 +1 @@
export * from './card.repository';

View File

@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import moment from 'moment';
import { Repository } from 'typeorm';
import { CardTransactionWebhookRequest } from '~/common/modules/neoleap/dtos/requests';
import { Card } from '../entities';
import { Transaction } from '../entities/transaction.entity';
import { TransactionType } from '../enums';
@Injectable()
export class TransactionRepository {
constructor(@InjectRepository(Transaction) private transactionRepository: Repository<Transaction>) {}
createCardTransaction(card: Card, transactionData: CardTransactionWebhookRequest): Promise<Transaction> {
return this.transactionRepository.save(
this.transactionRepository.create({
transactionId: transactionData.transactionId,
cardReference: transactionData.cardId,
transactionAmount: transactionData.transactionAmount,
transactionCurrency: transactionData.transactionCurrency,
billingAmount: transactionData.billingAmount,
settlementAmount: transactionData.settlementAmount,
transactionDate: moment(transactionData.date + transactionData.time, 'YYYYMMDDHHmmss').toDate(),
rrn: transactionData.rrn,
cardMaskedNumber: transactionData.cardMaskedNumber,
fees: transactionData.fees,
cardId: card.id,
accountId: card.account!.id,
transactionType: TransactionType.EXTERNAL,
accountReference: card.account!.accountReference,
}),
);
}
}

View File

@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { Account } from '../entities/account.entity';
import { AccountRepository } from '../repositories/account.repository';
@Injectable()
export class AccountService {
constructor(private readonly accountRepository: AccountRepository) {}
createAccount(accountId: string): Promise<Account> {
return this.accountRepository.createAccount(accountId);
}
}

View File

@ -0,0 +1,37 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Transactional } from 'typeorm-transactional';
import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response';
import { Card } from '../entities';
import { CardRepository } from '../repositories';
import { AccountService } from './account.service';
@Injectable()
export class CardService {
constructor(private readonly cardRepository: CardRepository, private readonly accountService: AccountService) {}
@Transactional()
async createCard(customerId: string, cardData: CreateApplicationResponse): Promise<Card> {
const account = await this.accountService.createAccount(cardData.accountId);
return this.cardRepository.createCard(customerId, account.id, cardData);
}
async getCardById(id: string): Promise<Card> {
const card = await this.cardRepository.getCardById(id);
if (!card) {
throw new BadRequestException('CARD.NOT_FOUND');
}
return card;
}
async getCardByReferenceNumber(referenceNumber: string): Promise<Card> {
const card = await this.cardRepository.getCardByReferenceNumber(referenceNumber);
if (!card) {
throw new BadRequestException('CARD.NOT_FOUND');
}
return card;
}
}

View File

@ -0,0 +1 @@
export * from './card.service';

View File

@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { CardTransactionWebhookRequest } from '~/common/modules/neoleap/dtos/requests';
import { TransactionRepository } from '../repositories/transaction.repository';
import { CardService } from './card.service';
@Injectable()
export class TransactionService {
constructor(
private readonly transactionRepository: TransactionRepository,
private readonly cardService: CardService,
) {}
async createCardTransaction(body: CardTransactionWebhookRequest) {
const card = await this.cardService.getCardByReferenceNumber(body.cardId);
return this.transactionRepository.createCardTransaction(card, body);
}
}

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');
}

View File

@ -9,7 +9,7 @@ export function buildValidationPipe(config: ConfigService): ValidationPipe {
transform: true,
validateCustomDecorators: true,
stopAtFirstError: true,
forbidNonWhitelisted: true,
forbidNonWhitelisted: false,
dismissDefaultMessages: true,
enableDebugMessages: config.getOrThrow('NODE_ENV') === Environment.DEV,
exceptionFactory: i18nValidationErrorFactory,

View File

@ -79,7 +79,7 @@ export class Customer extends BaseEntity {
userId!: string;
@Column('varchar', { name: 'country', length: 255, nullable: true })
country!: string;
country!: CountryIso;
@Column('varchar', { name: 'region', length: 255, nullable: true })
region!: string;

View File

@ -15,7 +15,7 @@ export class CustomerRepository {
findOne(where: FindOptionsWhere<Customer>) {
return this.customerRepository.findOne({
where,
relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack'],
relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack', 'cards'],
});
}

View File

@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateCardEntity1749633935436 implements MigrationInterface {
name = 'CreateCardEntity1749633935436';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "cards"
("id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"card_reference" character varying NOT NULL,
"first_six_digits" character varying(6) NOT NULL,
"last_four_digits" character varying(4) NOT NULL,
"expiry" character varying NOT NULL,
"customer_type" character varying NOT NULL,
"color" character varying NOT NULL DEFAULT 'BLUE',
"status" character varying NOT NULL DEFAULT 'PENDING',
"scheme" character varying NOT NULL DEFAULT 'VISA',
"issuer" character varying NOT NULL,
"customer_id" uuid NOT NULL,
"parent_id" uuid,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_9451069b6f1199730791a7f4ae4" PRIMARY KEY ("id"))`,
);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_6fb82820c0b1b7ec32a8dbf911" ON "card" ("card_reference") `);
await queryRunner.query(
`ALTER TABLE "card" ADD CONSTRAINT "FK_d46837f6ab27271d8125517d0b6" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "card" ADD CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "card" DROP CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e"`);
await queryRunner.query(`ALTER TABLE "card" DROP CONSTRAINT "FK_d46837f6ab27271d8125517d0b6"`);
await queryRunner.query(`DROP INDEX "public"."IDX_6fb82820c0b1b7ec32a8dbf911"`);
await queryRunner.query(`DROP TABLE "card"`);
}
}

View File

@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateAccountEntity1751456987627 implements MigrationInterface {
name = 'CreateAccountEntity1751456987627';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e"`);
await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_d46837f6ab27271d8125517d0b6"`);
await queryRunner.query(`DROP INDEX "public"."IDX_6fb82820c0b1b7ec32a8dbf911"`);
await queryRunner.query(
`CREATE TABLE "accounts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "account_reference" character varying(255) NOT NULL, "currency" character varying(255) NOT NULL, "balance" numeric(10,2) NOT NULL DEFAULT '0', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_829183fe026a0ce5fc8026b2417" UNIQUE ("account_reference"), CONSTRAINT "PK_5a7a02c20412299d198e097a8fe" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_829183fe026a0ce5fc8026b241" ON "accounts" ("account_reference") `,
);
await queryRunner.query(`ALTER TABLE "cards" ADD "account_id" uuid NOT NULL`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_4f7df5c5dc950295dc417a11d8" ON "cards" ("card_reference") `);
await queryRunner.query(
`ALTER TABLE "cards" ADD CONSTRAINT "FK_8ba18e7060c38ddae12e5bdf907" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "cards" ADD CONSTRAINT "FK_2fd0ee722ec57594d2e448c73d7" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "cards" ADD CONSTRAINT "FK_b2874ef49ff7da2dee49e4bc6d3" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_b2874ef49ff7da2dee49e4bc6d3"`);
await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_2fd0ee722ec57594d2e448c73d7"`);
await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_8ba18e7060c38ddae12e5bdf907"`);
await queryRunner.query(`DROP INDEX "public"."IDX_4f7df5c5dc950295dc417a11d8"`);
await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "account_id"`);
await queryRunner.query(`DROP INDEX "public"."IDX_829183fe026a0ce5fc8026b241"`);
await queryRunner.query(`DROP TABLE "accounts"`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_6fb82820c0b1b7ec32a8dbf911" ON "cards" ("card_reference") `);
await queryRunner.query(
`ALTER TABLE "cards" ADD CONSTRAINT "FK_d46837f6ab27271d8125517d0b6" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "cards" ADD CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
}

View File

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateTransactionTable1751466314709 implements MigrationInterface {
name = 'CreateTransactionTable1751466314709'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "transactions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "card_reference" character varying, "transaction_type" character varying NOT NULL DEFAULT 'EXTERNAL', "account_reference" character varying, "transaction_id" character varying, "card_masked_number" character varying, "transaction_date" TIMESTAMP WITH TIME ZONE, "rrn" character varying, "transaction_amount" numeric(12,2) NOT NULL, "transaction_currency" character varying NOT NULL, "billing_amount" numeric(12,2) NOT NULL, "settlement_amount" numeric(12,2) NOT NULL, "fees" numeric(12,2) NOT NULL, "card_id" uuid, "account_id" uuid, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_9162bf9ab4e31961a8f7932974c" UNIQUE ("transaction_id"), CONSTRAINT "PK_a219afd8dd77ed80f5a862f1db9" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "transactions" ADD CONSTRAINT "FK_80ad48141be648db2d84ff32f79" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "transactions" ADD CONSTRAINT "FK_49c0d6e8ba4bfb5582000d851f0" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_49c0d6e8ba4bfb5582000d851f0"`);
await queryRunner.query(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_80ad48141be648db2d84ff32f79"`);
await queryRunner.query(`DROP TABLE "transactions"`);
}
}

View File

@ -25,3 +25,6 @@ export * from './1740045960580-create-user-registration-table';
export * from './1741087742821-add-used-flag-to-otp-and-remove-constraints-from-customers';
export * from './1742112997024-update-customer-table';
export * from './1747569536067-add-address-fields-to-customers';
export * from './1749633935436-create-card-entity';
export * from './1751456987627-create-account-entity';
export * from './1751466314709-create-transaction-table';