mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-07-09 22:57:26 +00:00
feat: finish working on account transaction webhook
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@ -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,
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
||||
|
4
src/card/enums/transaction-scope.enum.ts
Normal file
4
src/card/enums/transaction-scope.enum.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum TransactionScope {
|
||||
CARD = 'CARD',
|
||||
ACCOUNT = 'ACCOUNT',
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
|
||||
|
@ -1 +1,2 @@
|
||||
export * from './account-transaction-webhook.request.dto';
|
||||
export * from './card-transaction-webhook.request.dto';
|
||||
|
16
src/db/migrations/1752056898465-edit-transaction-table.ts
Normal file
16
src/db/migrations/1752056898465-edit-transaction-table.ts
Normal 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"`);
|
||||
}
|
||||
|
||||
}
|
@ -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';
|
||||
|
Reference in New Issue
Block a user