feat: finish working on account transaction webhook

This commit is contained in:
Abdalhamid Alhamad
2025-07-09 13:31:08 +03:00
parent 3b3f8c0104
commit 038b8ef6e3
15 changed files with 249 additions and 11 deletions

7
package-lock.json generated
View File

@ -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,

View File

@ -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",

View File

@ -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;
}

View File

@ -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';

View File

@ -0,0 +1,4 @@
export enum TransactionScope {
CARD = 'CARD',
ACCOUNT = 'ACCOUNT',
}

View File

@ -16,4 +16,19 @@ export class AccountRepository {
}),
);
}
getAccountByReferenceNumber(accountReference: string): Promise<Account | null> {
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);
}
}

View File

@ -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<Transaction> {
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<Transaction | null> {
return this.transactionRepository.findOne({
where: { transactionId, accountReference },
});
}
}

View File

@ -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<Account> {
return this.accountRepository.createAccount(accountId);
}
async getAccountByReferenceNumber(accountReference: string): Promise<Account> {
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);
}
}

View File

@ -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<Transaction | null> {
const existingTransaction = await this.transactionRepository.findTransactionByReference(
transactionId,
accountReference,
);
return existingTransaction;
}
}

View File

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

View File

@ -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;
}

View File

@ -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;

View File

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

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class EditTransactionTable1752056898465 implements MigrationInterface {
name = 'EditTransactionTable1752056898465'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "vat_on_fees"`);
await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "transaction_scope"`);
}
}

View File

@ -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';