fix: validate card spending limit before transfering to child

This commit is contained in:
Abdalhameed Ahmad
2025-09-07 21:18:49 +03:00
parent d768da70f2
commit 15a48e4884
10 changed files with 75 additions and 2 deletions

View File

@ -1,5 +1,5 @@
import { Controller, Get, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums'; import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces'; import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators'; import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
@ -7,6 +7,7 @@ import { AccessTokenGuard, RolesGuard } from '~/common/guards';
import { CardEmbossingDetailsResponseDto } from '~/common/modules/neoleap/dtos/response'; import { CardEmbossingDetailsResponseDto } from '~/common/modules/neoleap/dtos/response';
import { ApiDataResponse } from '~/core/decorators'; import { ApiDataResponse } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils'; import { ResponseFactory } from '~/core/utils';
import { FundIbanRequestDto } from '../dtos/requests';
import { AccountIbanResponseDto, CardResponseDto } from '../dtos/responses'; import { AccountIbanResponseDto, CardResponseDto } from '../dtos/responses';
import { CardService } from '../services'; import { CardService } from '../services';
@ -46,4 +47,13 @@ export class CardsController {
const iban = await this.cardService.getIbanInformation(sub); const iban = await this.cardService.getIbanInformation(sub);
return ResponseFactory.data(new AccountIbanResponseDto(iban)); return ResponseFactory.data(new AccountIbanResponseDto(iban));
} }
@Post('mock/fund-iban')
@ApiOperation({ summary: 'Mock endpoint to fund the IBAN - For testing purposes only' })
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@HttpCode(HttpStatus.NO_CONTENT)
fundIban(@Body() { amount, iban }: FundIbanRequestDto) {
return this.cardService.fundIban(iban, amount);
}
} }

View File

@ -0,0 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { TransferToJuniorRequestDto } from '~/junior/dtos/request';
export class FundIbanRequestDto extends TransferToJuniorRequestDto {
@ApiProperty({ example: 'DE89370400440532013000' })
iban!: string;
}

View File

@ -0,0 +1 @@
export * from './fund-iban.request.dto';

View File

@ -27,6 +27,13 @@ export class AccountRepository {
}); });
} }
getAccountByIban(iban: string): Promise<Account | null> {
return this.accountRepository.findOne({
where: { iban },
relations: ['cards'],
});
}
getAccountByAccountNumber(accountNumber: string): Promise<Account | null> { getAccountByAccountNumber(accountNumber: string): Promise<Account | null> {
return this.accountRepository.findOne({ return this.accountRepository.findOne({
where: { accountNumber }, where: { accountNumber },

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Decimal } from 'decimal.js';
import moment from 'moment'; import moment from 'moment';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { import {
@ -84,4 +85,16 @@ export class TransactionRepository {
where: { transactionId, accountReference }, where: { transactionId, accountReference },
}); });
} }
findInternalTransactionTotal(accountId: string): Promise<number> {
return this.transactionRepository
.find({
where: { accountId, transactionType: TransactionType.INTERNAL },
})
.then((transactions) => {
return transactions
.reduce((total, tx) => new Decimal(total).plus(tx.transactionAmount), new Decimal(0))
.toNumber();
});
}
} }

View File

@ -27,6 +27,14 @@ export class AccountService {
return account; return account;
} }
async getAccountByIban(iban: string): Promise<Account> {
const account = await this.accountRepository.getAccountByIban(iban);
if (!account) {
throw new UnprocessableEntityException('ACCOUNT.NOT_FOUND');
}
return account;
}
creditAccountBalance(accountReference: string, amount: number) { creditAccountBalance(accountReference: string, amount: number) {
return this.accountRepository.topUpAccountBalance(accountReference, amount); return this.accountRepository.topUpAccountBalance(accountReference, amount);
} }
@ -52,4 +60,10 @@ export class AccountService {
return this.accountRepository.decreaseAccountBalance(accountReference, amount); return this.accountRepository.decreaseAccountBalance(accountReference, amount);
} }
//THIS IS A MOCK FUNCTION FOR TESTING PURPOSES ONLY
async fundIban(iban: string, amount: number) {
const account = await this.getAccountByIban(iban);
return this.accountRepository.topUpAccountBalance(account.accountReference, amount);
}
} }

View File

@ -119,6 +119,11 @@ export class CardService {
@Transactional() @Transactional()
async transferToChild(juniorId: string, amount: number) { async transferToChild(juniorId: string, amount: number) {
const card = await this.getCardByCustomerId(juniorId); const card = await this.getCardByCustomerId(juniorId);
const availableSpendingLimit = await this.transactionService.calculateAvailableSpendingLimitForParent(card.account);
if (amount > availableSpendingLimit) {
throw new BadRequestException('CARD.INSUFFICIENT_BALANCE');
}
const finalAmount = Decimal(amount).plus(card.limit); const finalAmount = Decimal(amount).plus(card.limit);
await Promise.all([ await Promise.all([
@ -129,4 +134,8 @@ export class CardService {
return finalAmount.toNumber(); return finalAmount.toNumber();
} }
fundIban(iban: string, amount: number) {
return this.accountService.fundIban(iban, amount);
}
} }

View File

@ -5,6 +5,7 @@ import {
AccountTransactionWebhookRequest, AccountTransactionWebhookRequest,
CardTransactionWebhookRequest, CardTransactionWebhookRequest,
} from '~/common/modules/neoleap/dtos/requests'; } from '~/common/modules/neoleap/dtos/requests';
import { Account } from '../entities/account.entity';
import { Transaction } from '../entities/transaction.entity'; import { Transaction } from '../entities/transaction.entity';
import { TransactionRepository } from '../repositories/transaction.repository'; import { TransactionRepository } from '../repositories/transaction.repository';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
@ -57,6 +58,11 @@ export class TransactionService {
return transaction; return transaction;
} }
async calculateAvailableSpendingLimitForParent(account: Account): Promise<number> {
const internalTransactionSum = await this.transactionRepository.findInternalTransactionTotal(account.id);
return new Decimal(account.balance).minus(internalTransactionSum).toNumber();
}
private async findExistingTransaction(transactionId: string, accountReference: string): Promise<Transaction | null> { private async findExistingTransaction(transactionId: string, accountReference: string): Promise<Transaction | null> {
const existingTransaction = await this.transactionRepository.findTransactionByReference( const existingTransaction = await this.transactionRepository.findTransactionByReference(
transactionId, transactionId,

View File

@ -103,5 +103,8 @@
}, },
"OTP": { "OTP": {
"INVALID_OTP": "رمز التحقق الذي أدخلته غير صالح. يرجى المحاولة مرة أخرى." "INVALID_OTP": "رمز التحقق الذي أدخلته غير صالح. يرجى المحاولة مرة أخرى."
},
"CARD": {
"INSUFFICIENT_BALANCE": "البطاقة لا تحتوي على رصيد كافٍ لإكمال هذا التحويل."
} }
} }

View File

@ -102,5 +102,8 @@
}, },
"OTP": { "OTP": {
"INVALID_OTP": "The OTP you entered is invalid. Please try again." "INVALID_OTP": "The OTP you entered is invalid. Please try again."
},
"CARD": {
"INSUFFICIENT_BALANCE": "The card does not have sufficient balance to complete this transfer."
} }
} }