From 038b8ef6e3657e56d006624ab050d4d193cb360c Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Wed, 9 Jul 2025 13:31:08 +0300 Subject: [PATCH] feat: finish working on account transaction webhook --- package-lock.json | 7 ++ package.json | 1 + src/card/entities/transaction.entity.ts | 13 +++- src/card/enums/index.ts | 1 + src/card/enums/transaction-scope.enum.ts | 4 ++ src/card/repositories/account.repository.ts | 15 ++++ .../repositories/transaction.repository.ts | 35 +++++++++- src/card/services/account.service.ts | 28 +++++++- src/card/services/transaction.service.ts | 56 ++++++++++++++- .../neoleap-webhooks.controller.ts | 7 +- ...account-transaction-webhook.request.dto.ts | 68 +++++++++++++++++++ .../card-transaction-webhook.request.dto.ts | 7 +- .../modules/neoleap/dtos/requests/index.ts | 1 + .../1752056898465-edit-transaction-table.ts | 16 +++++ src/db/migrations/index.ts | 1 + 15 files changed, 249 insertions(+), 11 deletions(-) create mode 100644 src/card/enums/transaction-scope.enum.ts create mode 100644 src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts create mode 100644 src/db/migrations/1752056898465-edit-transaction-table.ts diff --git a/package-lock.json b/package-lock.json index 2a54f0c..3cd82fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "cacheable": "^1.8.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "decimal.js": "^10.6.0", "firebase-admin": "^13.0.2", "google-libphonenumber": "^3.2.39", "handlebars": "^4.7.8", @@ -5167,6 +5168,12 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.5.3", "dev": true, diff --git a/package.json b/package.json index a991cb2..f486a74 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "cacheable": "^1.8.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "decimal.js": "^10.6.0", "firebase-admin": "^13.0.2", "google-libphonenumber": "^3.2.39", "handlebars": "^4.7.8", diff --git a/src/card/entities/transaction.entity.ts b/src/card/entities/transaction.entity.ts index 6e4d693..029ef29 100644 --- a/src/card/entities/transaction.entity.ts +++ b/src/card/entities/transaction.entity.ts @@ -1,5 +1,5 @@ import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; -import { TransactionType } from '../enums'; +import { TransactionScope, TransactionType } from '../enums'; import { Account } from './account.entity'; import { Card } from './card.entity'; @@ -8,12 +8,15 @@ export class Transaction { @PrimaryGeneratedColumn('uuid') id!: string; - @Column({ name: 'card_reference', nullable: true, type: 'varchar' }) - cardReference!: string; + @Column({ name: 'transaction_scope', type: 'varchar', nullable: false }) + transactionScope!: TransactionScope; @Column({ name: 'transaction_type', type: 'varchar', default: TransactionType.EXTERNAL }) transactionType!: TransactionType; + @Column({ name: 'card_reference', nullable: true, type: 'varchar' }) + cardReference!: string; + @Column({ name: 'account_reference', nullable: true, type: 'varchar' }) accountReference!: string; @@ -44,6 +47,9 @@ export class Transaction { @Column({ type: 'decimal', name: 'fees', precision: 12, scale: 2 }) fees!: number; + @Column({ type: 'decimal', name: 'vat_on_fees', precision: 12, scale: 2, default: 0.0 }) + vatOnFees!: number; + @Column({ name: 'card_id', type: 'uuid', nullable: true }) cardId!: string; @@ -57,6 +63,7 @@ export class Transaction { @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 03f577c..cac8b22 100644 --- a/src/card/enums/index.ts +++ b/src/card/enums/index.ts @@ -3,4 +3,5 @@ export * from './card-issuers.enum'; export * from './card-scheme.enum'; export * from './card-status.enum'; export * from './customer-type.enum'; +export * from './transaction-scope.enum'; export * from './transaction-type.enum'; diff --git a/src/card/enums/transaction-scope.enum.ts b/src/card/enums/transaction-scope.enum.ts new file mode 100644 index 0000000..d1afd17 --- /dev/null +++ b/src/card/enums/transaction-scope.enum.ts @@ -0,0 +1,4 @@ +export enum TransactionScope { + CARD = 'CARD', + ACCOUNT = 'ACCOUNT', +} diff --git a/src/card/repositories/account.repository.ts b/src/card/repositories/account.repository.ts index 5f4a357..da15be6 100644 --- a/src/card/repositories/account.repository.ts +++ b/src/card/repositories/account.repository.ts @@ -16,4 +16,19 @@ export class AccountRepository { }), ); } + + getAccountByReferenceNumber(accountReference: string): Promise { + return this.accountRepository.findOne({ + where: { accountReference }, + relations: ['cards'], + }); + } + + topUpAccountBalance(accountReference: string, amount: number) { + return this.accountRepository.increment({ accountReference }, 'balance', amount); + } + + decreaseAccountBalance(accountReference: string, amount: number) { + return this.accountRepository.decrement({ accountReference }, 'balance', amount); + } } diff --git a/src/card/repositories/transaction.repository.ts b/src/card/repositories/transaction.repository.ts index fa11dd8..39d8665 100644 --- a/src/card/repositories/transaction.repository.ts +++ b/src/card/repositories/transaction.repository.ts @@ -2,10 +2,14 @@ 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 { + AccountTransactionWebhookRequest, + CardTransactionWebhookRequest, +} from '~/common/modules/neoleap/dtos/requests'; import { Card } from '../entities'; +import { Account } from '../entities/account.entity'; import { Transaction } from '../entities/transaction.entity'; -import { TransactionType } from '../enums'; +import { TransactionScope, TransactionType } from '../enums'; @Injectable() export class TransactionRepository { @@ -28,7 +32,34 @@ export class TransactionRepository { accountId: card.account!.id, transactionType: TransactionType.EXTERNAL, accountReference: card.account!.accountReference, + transactionScope: TransactionScope.CARD, + vatOnFees: transactionData.vatOnFees, }), ); } + + createAccountTransaction(account: Account, transactionData: AccountTransactionWebhookRequest): Promise { + return this.transactionRepository.save( + this.transactionRepository.create({ + transactionId: transactionData.transactionId, + transactionAmount: transactionData.amount, + transactionCurrency: transactionData.currency, + billingAmount: 0, + settlementAmount: 0, + transactionDate: moment(transactionData.date + transactionData.time, 'YYYYMMDDHHmmss').toDate(), + fees: 0, + accountReference: account.accountReference, + accountId: account.id, + transactionType: TransactionType.EXTERNAL, + transactionScope: TransactionScope.ACCOUNT, + vatOnFees: 0, + }), + ); + } + + findTransactionByReference(transactionId: string, accountReference: string): Promise { + return this.transactionRepository.findOne({ + where: { transactionId, accountReference }, + }); + } } diff --git a/src/card/services/account.service.ts b/src/card/services/account.service.ts index a8c4791..cce8e68 100644 --- a/src/card/services/account.service.ts +++ b/src/card/services/account.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { Account } from '../entities/account.entity'; import { AccountRepository } from '../repositories/account.repository'; @@ -9,4 +9,30 @@ export class AccountService { createAccount(accountId: string): Promise { return this.accountRepository.createAccount(accountId); } + + async getAccountByReferenceNumber(accountReference: string): Promise { + const account = await this.accountRepository.getAccountByReferenceNumber(accountReference); + if (!account) { + throw new UnprocessableEntityException('ACCOUNT.NOT_FOUND'); + } + return account; + } + + async creditAccountBalance(accountReference: string, amount: number) { + return this.accountRepository.topUpAccountBalance(accountReference, amount); + } + + async decreaseAccountBalance(accountReference: string, amount: number) { + const account = await this.getAccountByReferenceNumber(accountReference); + /** + * While there is no need to check for insufficient balance because this is a webhook handler, + * I just added this check to ensure we don't have corruption in our data especially if this service is used elsewhere. + */ + + if (account.balance < amount) { + throw new UnprocessableEntityException('ACCOUNT.INSUFFICIENT_BALANCE'); + } + + return this.accountRepository.decreaseAccountBalance(accountReference, amount); + } } diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index 533bd16..6c98b9c 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -1,6 +1,13 @@ -import { Injectable } from '@nestjs/common'; -import { CardTransactionWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import Decimal from 'decimal.js'; +import { Transactional } from 'typeorm-transactional'; +import { + AccountTransactionWebhookRequest, + CardTransactionWebhookRequest, +} from '~/common/modules/neoleap/dtos/requests'; +import { Transaction } from '../entities/transaction.entity'; import { TransactionRepository } from '../repositories/transaction.repository'; +import { AccountService } from './account.service'; import { CardService } from './card.service'; @Injectable() @@ -8,9 +15,52 @@ export class TransactionService { constructor( private readonly transactionRepository: TransactionRepository, private readonly cardService: CardService, + private readonly accountService: AccountService, ) {} + + @Transactional() async createCardTransaction(body: CardTransactionWebhookRequest) { const card = await this.cardService.getCardByReferenceNumber(body.cardId); - return this.transactionRepository.createCardTransaction(card, body); + const existingTransaction = await this.findExistingTransaction(body.transactionId, card.account.accountReference); + + if (existingTransaction) { + throw new UnprocessableEntityException('TRANSACTION.ALREADY_EXISTS'); + } + + const transaction = await this.transactionRepository.createCardTransaction(card, body); + const total = new Decimal(body.transactionAmount) + .plus(body.billingAmount) + .plus(body.settlementAmount) + .plus(body.fees) + .plus(body.vatOnFees); + + await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()); + + return transaction; + } + + @Transactional() + async createAccountTransaction(body: AccountTransactionWebhookRequest) { + const account = await this.accountService.getAccountByReferenceNumber(body.accountId); + + const existingTransaction = await this.findExistingTransaction(body.transactionId, account.accountReference); + + if (existingTransaction) { + throw new UnprocessableEntityException('TRANSACTION.ALREADY_EXISTS'); + } + + const transaction = await this.transactionRepository.createAccountTransaction(account, body); + await this.accountService.creditAccountBalance(account.accountReference, body.amount); + + return transaction; + } + + private async findExistingTransaction(transactionId: string, accountReference: string): Promise { + const existingTransaction = await this.transactionRepository.findTransactionByReference( + transactionId, + accountReference, + ); + + return existingTransaction; } } diff --git a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts index ae0aadb..05d3bf9 100644 --- a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts +++ b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TransactionService } from '~/card/services/transaction.service'; -import { CardTransactionWebhookRequest } from '../dtos/requests'; +import { AccountTransactionWebhookRequest, CardTransactionWebhookRequest } from '../dtos/requests'; @Controller('neoleap-webhooks') @ApiTags('Neoleap Webhooks') @@ -12,4 +12,9 @@ export class NeoLeapWebhooksController { async handleCardTransactionWebhook(@Body() body: CardTransactionWebhookRequest) { return this.transactionService.createCardTransaction(body); } + + @Post('account-transaction') + async handleAccountTransactionWebhook(@Body() body: AccountTransactionWebhookRequest) { + return this.transactionService.createAccountTransaction(body); + } } diff --git a/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts new file mode 100644 index 0000000..06e1437 --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts @@ -0,0 +1,68 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IsNumber, IsPositive, IsString } from 'class-validator'; + +export class AccountTransactionWebhookRequest { + @Expose({ name: 'InstId' }) + @IsString() + @ApiProperty({ name: 'InstId', example: '9000' }) + instId!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '143761' }) + transactionId!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '0037' }) + transactionType!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '26' }) + transactionCode!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '6823000000000018' }) + accountNumber!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '6823000000000018' }) + accountId!: string; + + @Expose() + @Type(() => Number) + @IsNumber() + @ApiProperty({ example: '7080.15' }) + otb!: number; + + @Expose() + @Type(() => Number) + @IsNumber() + @IsPositive() + @ApiProperty({ example: '3050.95' }) + amount!: number; + + @Expose() + @IsString() + @ApiProperty({ example: 'C' }) + sign!: string; + + @Expose() + @IsString() + @ApiProperty({ example: '682' }) + currency!: string; + + @Expose() + @IsString() + @ApiProperty({ name: 'Date', example: '20241112' }) + date!: string; + + @Expose() + @IsString() + @ApiProperty({ name: 'Time', example: '125340' }) + time!: string; +} 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 index 8a59b19..db8afe5 100644 --- 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 @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Expose, Type } from 'class-transformer'; -import { IsNumber, IsString, ValidateNested } from 'class-validator'; +import { IsNumber, IsString, Min, ValidateNested } from 'class-validator'; export class CardAcceptorLocationDto { @Expose() @@ -99,6 +99,7 @@ export class CardTransactionWebhookRequest { @Type(() => Number) @IsNumber() + @Min(0, { message: 'amount must be zero or a positive number' }) @ApiProperty({ example: '100.5' }) transactionAmount!: number; @@ -108,6 +109,7 @@ export class CardTransactionWebhookRequest { @Type(() => Number) @IsNumber() + @Min(0, { message: 'amount must be zero or a positive number' }) @ApiProperty({ example: '100.5' }) billingAmount!: number; @@ -117,6 +119,7 @@ export class CardTransactionWebhookRequest { @Type(() => Number) @IsNumber() + @Min(0, { message: 'amount must be zero or a positive number' }) @ApiProperty({ example: '100.5' }) settlementAmount!: number; @@ -127,12 +130,14 @@ export class CardTransactionWebhookRequest { @Expose() @Type(() => Number) @IsNumber() + @Min(0, { message: 'amount must be zero or a positive number' }) @ApiProperty({ example: '20' }) fees!: number; @Expose() @Type(() => Number) @IsNumber() + @Min(0, { message: 'amount must be zero or a positive number' }) @ApiProperty({ example: '4.5' }) vatOnFees!: number; diff --git a/src/common/modules/neoleap/dtos/requests/index.ts b/src/common/modules/neoleap/dtos/requests/index.ts index a62ed5c..bf49fc9 100644 --- a/src/common/modules/neoleap/dtos/requests/index.ts +++ b/src/common/modules/neoleap/dtos/requests/index.ts @@ -1 +1,2 @@ +export * from './account-transaction-webhook.request.dto'; export * from './card-transaction-webhook.request.dto'; diff --git a/src/db/migrations/1752056898465-edit-transaction-table.ts b/src/db/migrations/1752056898465-edit-transaction-table.ts new file mode 100644 index 0000000..d4bab41 --- /dev/null +++ b/src/db/migrations/1752056898465-edit-transaction-table.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class EditTransactionTable1752056898465 implements MigrationInterface { + name = 'EditTransactionTable1752056898465' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "transactions" ADD "transaction_scope" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "transactions" ADD "vat_on_fees" numeric(12,2) NOT NULL DEFAULT '0'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "vat_on_fees"`); + await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "transaction_scope"`); + } + +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 9e5e580..5efe087 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -28,3 +28,4 @@ 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'; +export * from './1752056898465-edit-transaction-table';