From d3057beb54384afca9799fe11f37c838c2e9b9fe Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Wed, 2 Jul 2025 18:42:38 +0300 Subject: [PATCH] feat: add transaction, card , and account entities --- src/card/card.module.ts | 19 ++- src/card/entities/account.entity.ts | 31 ++++ src/card/entities/card.entity.ts | 23 ++- src/card/entities/transaction.entity.ts | 62 +++++++ src/card/enums/index.ts | 1 + src/card/enums/transaction-type.enum.ts | 4 + src/card/repositories/account.repository.ts | 19 +++ src/card/repositories/card.repository.ts | 37 +++++ src/card/repositories/index.ts | 1 + .../repositories/transaction.repository.ts | 34 ++++ src/card/services/account.service.ts | 12 ++ src/card/services/card.service.ts | 37 +++++ src/card/services/index.ts | 1 + src/card/services/transaction.service.ts | 16 ++ .../neoleap-webhooks.controller.ts | 14 ++ .../neoleap/controllers/neotest.controller.ts | 25 ++- .../card-transaction-webhook.request.dto.ts | 153 ++++++++++++++++++ .../modules/neoleap/dtos/requests/index.ts | 1 + .../create-application.response.dto.ts | 32 +++- .../response/inquire-application.response.ts | 36 +++++ src/common/modules/neoleap/neoleap.module.ts | 6 +- .../neoleap/services/neoleap.service.ts | 15 +- src/core/pipes/validation.pipe.ts | 2 +- src/customer/entities/customer.entity.ts | 2 +- .../repositories/customer.repository.ts | 2 +- .../1749633935436-create-card-entity.ts | 40 +++++ .../1751456987627-create-account-entity.ts | 45 ++++++ .../1751466314709-create-transaction-table.ts | 18 +++ src/db/migrations/index.ts | 3 + 29 files changed, 670 insertions(+), 21 deletions(-) create mode 100644 src/card/entities/account.entity.ts create mode 100644 src/card/entities/transaction.entity.ts create mode 100644 src/card/enums/transaction-type.enum.ts create mode 100644 src/card/repositories/account.repository.ts create mode 100644 src/card/repositories/card.repository.ts create mode 100644 src/card/repositories/index.ts create mode 100644 src/card/repositories/transaction.repository.ts create mode 100644 src/card/services/account.service.ts create mode 100644 src/card/services/card.service.ts create mode 100644 src/card/services/index.ts create mode 100644 src/card/services/transaction.service.ts create mode 100644 src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts create mode 100644 src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts create mode 100644 src/common/modules/neoleap/dtos/requests/index.ts create mode 100644 src/db/migrations/1749633935436-create-card-entity.ts create mode 100644 src/db/migrations/1751456987627-create-account-entity.ts create mode 100644 src/db/migrations/1751466314709-create-transaction-table.ts diff --git a/src/card/card.module.ts b/src/card/card.module.ts index f6f616f..92500ad 100644 --- a/src/card/card.module.ts +++ b/src/card/card.module.ts @@ -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 {} diff --git a/src/card/entities/account.entity.ts b/src/card/entities/account.entity.ts new file mode 100644 index 0000000..cdd7847 --- /dev/null +++ b/src/card/entities/account.entity.ts @@ -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; +} diff --git a/src/card/entities/card.entity.ts b/src/card/entities/card.entity.ts index bc992da..4984a61 100644 --- a/src/card/entities/card.entity.ts +++ b/src/card/entities/card.entity.ts @@ -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; diff --git a/src/card/entities/transaction.entity.ts b/src/card/entities/transaction.entity.ts new file mode 100644 index 0000000..6e4d693 --- /dev/null +++ b/src/card/entities/transaction.entity.ts @@ -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; +} diff --git a/src/card/enums/index.ts b/src/card/enums/index.ts index 6a9d197..03f577c 100644 --- a/src/card/enums/index.ts +++ b/src/card/enums/index.ts @@ -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'; diff --git a/src/card/enums/transaction-type.enum.ts b/src/card/enums/transaction-type.enum.ts new file mode 100644 index 0000000..a65e819 --- /dev/null +++ b/src/card/enums/transaction-type.enum.ts @@ -0,0 +1,4 @@ +export enum TransactionType { + INTERNAL = 'INTERNAL', + EXTERNAL = 'EXTERNAL', +} diff --git a/src/card/repositories/account.repository.ts b/src/card/repositories/account.repository.ts new file mode 100644 index 0000000..5f4a357 --- /dev/null +++ b/src/card/repositories/account.repository.ts @@ -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) {} + + createAccount(accountId: string): Promise { + return this.accountRepository.save( + this.accountRepository.create({ + accountReference: accountId, + balance: 0, + currency: '682', + }), + ); + } +} diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts new file mode 100644 index 0000000..a83d71c --- /dev/null +++ b/src/card/repositories/card.repository.ts @@ -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) {} + + createCard(customerId: string, accountId: string, card: CreateApplicationResponse): Promise { + 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 { + return this.cardRepository.findOne({ where: { id } }); + } + + getCardByReferenceNumber(referenceNumber: string): Promise { + return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] }); + } +} diff --git a/src/card/repositories/index.ts b/src/card/repositories/index.ts new file mode 100644 index 0000000..8458740 --- /dev/null +++ b/src/card/repositories/index.ts @@ -0,0 +1 @@ +export * from './card.repository'; diff --git a/src/card/repositories/transaction.repository.ts b/src/card/repositories/transaction.repository.ts new file mode 100644 index 0000000..fa11dd8 --- /dev/null +++ b/src/card/repositories/transaction.repository.ts @@ -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) {} + + createCardTransaction(card: Card, transactionData: CardTransactionWebhookRequest): Promise { + 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, + }), + ); + } +} diff --git a/src/card/services/account.service.ts b/src/card/services/account.service.ts new file mode 100644 index 0000000..a8c4791 --- /dev/null +++ b/src/card/services/account.service.ts @@ -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 { + return this.accountRepository.createAccount(accountId); + } +} diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts new file mode 100644 index 0000000..25573da --- /dev/null +++ b/src/card/services/card.service.ts @@ -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 { + const account = await this.accountService.createAccount(cardData.accountId); + return this.cardRepository.createCard(customerId, account.id, cardData); + } + + async getCardById(id: string): Promise { + const card = await this.cardRepository.getCardById(id); + + if (!card) { + throw new BadRequestException('CARD.NOT_FOUND'); + } + + return card; + } + + async getCardByReferenceNumber(referenceNumber: string): Promise { + const card = await this.cardRepository.getCardByReferenceNumber(referenceNumber); + + if (!card) { + throw new BadRequestException('CARD.NOT_FOUND'); + } + + return card; + } +} diff --git a/src/card/services/index.ts b/src/card/services/index.ts new file mode 100644 index 0000000..ea35f0f --- /dev/null +++ b/src/card/services/index.ts @@ -0,0 +1 @@ +export * from './card.service'; diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts new file mode 100644 index 0000000..533bd16 --- /dev/null +++ b/src/card/services/transaction.service.ts @@ -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); + } +} diff --git a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts new file mode 100644 index 0000000..0089a66 --- /dev/null +++ b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts @@ -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); + } +} diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts index b8a3250..5d8ff8a 100644 --- a/src/common/modules/neoleap/controllers/neotest.controller.ts +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -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('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); } } diff --git a/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts new file mode 100644 index 0000000..8a59b19 --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts @@ -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; +} diff --git a/src/common/modules/neoleap/dtos/requests/index.ts b/src/common/modules/neoleap/dtos/requests/index.ts new file mode 100644 index 0000000..a62ed5c --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/index.ts @@ -0,0 +1 @@ +export * from './card-transaction-webhook.request.dto'; diff --git a/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts index 3e186f8..f135b4f 100644 --- a/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts +++ b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts @@ -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; } diff --git a/src/common/modules/neoleap/dtos/response/inquire-application.response.ts b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts index b31c69d..aeb7165 100644 --- a/src/common/modules/neoleap/dtos/response/inquire-application.response.ts +++ b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts @@ -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; } diff --git a/src/common/modules/neoleap/neoleap.module.ts b/src/common/modules/neoleap/neoleap.module.ts index 232bef5..78c0224 100644 --- a/src/common/modules/neoleap/neoleap.module.ts +++ b/src/common/modules/neoleap/neoleap.module.ts @@ -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 {} diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index fd9fbce..3c7802b 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -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'); } diff --git a/src/core/pipes/validation.pipe.ts b/src/core/pipes/validation.pipe.ts index 505fb03..5153b10 100644 --- a/src/core/pipes/validation.pipe.ts +++ b/src/core/pipes/validation.pipe.ts @@ -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, diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index b8bdc65..96786c0 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -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; diff --git a/src/customer/repositories/customer.repository.ts b/src/customer/repositories/customer.repository.ts index a17b383..85565a6 100644 --- a/src/customer/repositories/customer.repository.ts +++ b/src/customer/repositories/customer.repository.ts @@ -15,7 +15,7 @@ export class CustomerRepository { findOne(where: FindOptionsWhere) { return this.customerRepository.findOne({ where, - relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack'], + relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack', 'cards'], }); } diff --git a/src/db/migrations/1749633935436-create-card-entity.ts b/src/db/migrations/1749633935436-create-card-entity.ts new file mode 100644 index 0000000..4ae45ab --- /dev/null +++ b/src/db/migrations/1749633935436-create-card-entity.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateCardEntity1749633935436 implements MigrationInterface { + name = 'CreateCardEntity1749633935436'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/src/db/migrations/1751456987627-create-account-entity.ts b/src/db/migrations/1751456987627-create-account-entity.ts new file mode 100644 index 0000000..40e7a1b --- /dev/null +++ b/src/db/migrations/1751456987627-create-account-entity.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAccountEntity1751456987627 implements MigrationInterface { + name = 'CreateAccountEntity1751456987627'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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`, + ); + } +} diff --git a/src/db/migrations/1751466314709-create-transaction-table.ts b/src/db/migrations/1751466314709-create-transaction-table.ts new file mode 100644 index 0000000..4c5e773 --- /dev/null +++ b/src/db/migrations/1751466314709-create-transaction-table.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateTransactionTable1751466314709 implements MigrationInterface { + name = 'CreateTransactionTable1751466314709' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } + +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 3ad20d0..9e5e580 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -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';