From 039c95aa56ba305ec8d85567aa436c3b0b5cfc0a Mon Sep 17 00:00:00 2001 From: Abdalhameed Ahmad Date: Tue, 9 Sep 2025 20:31:48 +0300 Subject: [PATCH] fix: calculating child and parent balance --- src/card/dtos/responses/card.response.dto.ts | 8 ++++++++ src/card/entities/account.entity.ts | 3 +++ src/card/repositories/account.repository.ts | 8 ++++++++ src/card/repositories/card.repository.ts | 3 ++- .../repositories/transaction.repository.ts | 13 ------------- src/card/services/account.service.ts | 15 ++++++++++++++- src/card/services/card.service.ts | 6 ++++-- src/card/services/transaction.service.ts | 16 +++++++++------- ...add-reservation-amount-to-account-entity.ts | 13 +++++++++++++ src/db/migrations/index.ts | 1 + .../dtos/response/junior.response.dto.ts | 5 +++++ src/junior/repositories/junior.repository.ts | 18 ++++++++++++++++-- 12 files changed, 83 insertions(+), 26 deletions(-) create mode 100644 src/db/migrations/1757433339849-add-reservation-amount-to-account-entity.ts diff --git a/src/card/dtos/responses/card.response.dto.ts b/src/card/dtos/responses/card.response.dto.ts index 53c13f1..26f93c3 100644 --- a/src/card/dtos/responses/card.response.dto.ts +++ b/src/card/dtos/responses/card.response.dto.ts @@ -43,6 +43,13 @@ export class CardResponseDto { }) balance!: number; + @ApiProperty({ + example: 100.0, + nullable: true, + description: 'The reserved balance of the card (applicable for child accounts).', + }) + reservedBalance!: number | null; + constructor(card: Card) { this.id = card.id; this.firstSixDigits = card.firstSixDigits; @@ -52,5 +59,6 @@ export class CardResponseDto { this.statusDescription = CardStatusDescriptionMapper[card.statusDescription][UserLocale.ENGLISH].description; this.balance = card.customerType === CustomerType.CHILD ? Math.min(card.limit, card.account.balance) : card.account.balance; + this.reservedBalance = card.customerType === CustomerType.PARENT ? card.account.reservedBalance : null; } } diff --git a/src/card/entities/account.entity.ts b/src/card/entities/account.entity.ts index 4a1ffcb..d57bec8 100644 --- a/src/card/entities/account.entity.ts +++ b/src/card/entities/account.entity.ts @@ -25,6 +25,9 @@ export class Account { @Column('decimal', { precision: 10, scale: 2, default: 0.0, name: 'balance' }) balance!: number; + @Column('decimal', { precision: 10, scale: 2, default: 0.0, name: 'reserved_balance' }) + reservedBalance!: number; + @OneToMany(() => Card, (card) => card.account, { cascade: true }) cards!: Card[]; diff --git a/src/card/repositories/account.repository.ts b/src/card/repositories/account.repository.ts index ae1349f..2a5edc9 100644 --- a/src/card/repositories/account.repository.ts +++ b/src/card/repositories/account.repository.ts @@ -55,4 +55,12 @@ export class AccountRepository { decreaseAccountBalance(accountReference: string, amount: number) { return this.accountRepository.decrement({ accountReference }, 'balance', amount); } + + increaseReservedBalance(accountId: string, amount: number) { + return this.accountRepository.increment({ id: accountId }, 'reservedBalance', amount); + } + + decreaseReservedBalance(accountId: string, amount: number) { + return this.accountRepository.decrement({ id: accountId }, 'reservedBalance', amount); + } } diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts index cecfdb0..b8fc8be 100644 --- a/src/card/repositories/card.repository.ts +++ b/src/card/repositories/card.repository.ts @@ -14,13 +14,14 @@ export class CardRepository { accountId: string, card: CreateApplicationResponse, cardColor?: CardColors, + isChildCard = false, ): Promise { return this.cardRepository.save( this.cardRepository.create({ customerId: customerId, expiry: card.expiryDate, cardReference: card.cardId, - customerType: CustomerType.PARENT, + customerType: isChildCard ? CustomerType.CHILD : CustomerType.PARENT, firstSixDigits: card.firstSixDigits, lastFourDigits: card.lastFourDigits, color: cardColor ? cardColor : CardColors.DEEP_MAGENTA, diff --git a/src/card/repositories/transaction.repository.ts b/src/card/repositories/transaction.repository.ts index a61f55d..b38a257 100644 --- a/src/card/repositories/transaction.repository.ts +++ b/src/card/repositories/transaction.repository.ts @@ -1,6 +1,5 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Decimal } from 'decimal.js'; import moment from 'moment'; import { Repository } from 'typeorm'; import { @@ -85,16 +84,4 @@ export class TransactionRepository { where: { transactionId, accountReference }, }); } - - findInternalTransactionTotal(accountId: string): Promise { - 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(); - }); - } } diff --git a/src/card/services/account.service.ts b/src/card/services/account.service.ts index 4c6af29..2ab2d6a 100644 --- a/src/card/services/account.service.ts +++ b/src/card/services/account.service.ts @@ -49,9 +49,11 @@ export class AccountService { 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. + * I just added this check to ensure we don't have corruption in our data. */ if (account.balance < amount) { @@ -61,6 +63,17 @@ export class AccountService { return this.accountRepository.decreaseAccountBalance(accountReference, amount); } + increaseReservedBalance(account: Account, amount: number) { + if (account.balance < account.reservedBalance + amount) { + throw new UnprocessableEntityException('ACCOUNT.INSUFFICIENT_BALANCE'); + } + return this.accountRepository.increaseReservedBalance(account.id, amount); + } + + decrementReservedBalance(account: Account, amount: number) { + return this.accountRepository.decreaseReservedBalance(account.id, amount); + } + //THIS IS A MOCK FUNCTION FOR TESTING PURPOSES ONLY async fundIban(iban: string, amount: number) { const account = await this.getAccountByIban(iban); diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index 3aae9a2..fb8c8b7 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -49,6 +49,7 @@ export class CardService { parentCustomer.cards[0].account.id, data, cardColor, + true, ); return this.getCardById(createdCard.id); @@ -87,6 +88,7 @@ export class CardService { if (!card) { throw new BadRequestException('CARD.NOT_FOUND'); } + return card; } @@ -119,9 +121,8 @@ export class CardService { @Transactional() async transferToChild(juniorId: string, amount: number) { const card = await this.getCardByCustomerId(juniorId); - const availableSpendingLimit = await this.transactionService.calculateAvailableSpendingLimitForParent(card.account); - if (amount > availableSpendingLimit) { + if (amount > card.account.balance - card.account.reservedBalance) { throw new BadRequestException('CARD.INSUFFICIENT_BALANCE'); } @@ -129,6 +130,7 @@ export class CardService { await Promise.all([ this.neoleapService.updateCardControl(card.cardReference, finalAmount.toNumber()), this.updateCardLimit(card.id, finalAmount.toNumber()), + this.accountService.increaseReservedBalance(card.account, amount), this.transactionService.createInternalChildTransaction(card.id, amount), ]); diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index 839f370..f09d844 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -5,8 +5,8 @@ import { AccountTransactionWebhookRequest, CardTransactionWebhookRequest, } from '~/common/modules/neoleap/dtos/requests'; -import { Account } from '../entities/account.entity'; import { Transaction } from '../entities/transaction.entity'; +import { CustomerType } from '../enums'; import { TransactionRepository } from '../repositories/transaction.repository'; import { AccountService } from './account.service'; import { CardService } from './card.service'; @@ -31,7 +31,14 @@ export class TransactionService { const transaction = await this.transactionRepository.createCardTransaction(card, body); const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees); - await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()); + if (card.customerType === CustomerType.CHILD) { + await Promise.all([ + this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()), + this.accountService.decrementReservedBalance(card.account, total.toNumber()), + ]); + } else { + await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()); + } return transaction; } @@ -58,11 +65,6 @@ export class TransactionService { return transaction; } - async calculateAvailableSpendingLimitForParent(account: Account): Promise { - const internalTransactionSum = await this.transactionRepository.findInternalTransactionTotal(account.id); - return new Decimal(account.balance).minus(internalTransactionSum).toNumber(); - } - private async findExistingTransaction(transactionId: string, accountReference: string): Promise { const existingTransaction = await this.transactionRepository.findTransactionByReference( transactionId, diff --git a/src/db/migrations/1757433339849-add-reservation-amount-to-account-entity.ts b/src/db/migrations/1757433339849-add-reservation-amount-to-account-entity.ts new file mode 100644 index 0000000..0a64e18 --- /dev/null +++ b/src/db/migrations/1757433339849-add-reservation-amount-to-account-entity.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddReservationAmountToAccountEntity1757433339849 implements MigrationInterface { + name = 'AddReservationAmountToAccountEntity1757433339849'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "accounts" ADD "reserved_balance" numeric(10,2) NOT NULL DEFAULT '0'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "accounts" DROP COLUMN "reserved_balance"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 5f892a3..dbdede5 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -2,3 +2,4 @@ export * from './1754913378460-initial-migration'; export * from './1754915164809-create-neoleap-related-entities'; export * from './1754915164810-seed-default-avatar'; export * from './1757349525708-create-money-requests-table'; +export * from './1757433339849-add-reservation-amount-to-account-entity'; diff --git a/src/junior/dtos/response/junior.response.dto.ts b/src/junior/dtos/response/junior.response.dto.ts index 5a0436c..6e21cd0 100644 --- a/src/junior/dtos/response/junior.response.dto.ts +++ b/src/junior/dtos/response/junior.response.dto.ts @@ -32,7 +32,11 @@ export class JuniorResponseDto { @ApiProperty({ type: DocumentMetaResponseDto }) profilePicture!: DocumentMetaResponseDto | null; + @ApiProperty({ example: 2000.0, description: 'The available balance' }) + availableBalance!: number; + constructor(junior: Junior) { + const card = junior.customer.cards?.[0]; this.id = junior.id; this.firstName = junior.customer.firstName; this.lastName = junior.customer.lastName; @@ -41,6 +45,7 @@ export class JuniorResponseDto { this.dateOfBirth = junior.customer.dateOfBirth; this.relationship = junior.relationship; this.guardianRelationship = GuardianRelationship[junior.relationship]; + this.availableBalance = card ? Math.min(card.limit, card.account.balance) : 0; this.profilePicture = junior.customer.user.profilePicture ? new DocumentMetaResponseDto(junior.customer.user.profilePicture) : null; diff --git a/src/junior/repositories/junior.repository.ts b/src/junior/repositories/junior.repository.ts index 63db76d..4fea55e 100644 --- a/src/junior/repositories/junior.repository.ts +++ b/src/junior/repositories/junior.repository.ts @@ -13,14 +13,28 @@ export class JuniorRepository { findJuniorsByGuardianId(guardianId: string, pageOptions: PageOptionsRequestDto) { return this.juniorRepository.findAndCount({ where: { guardianId }, - relations: ['customer', 'customer.user', 'customer.user.profilePicture'], + relations: [ + 'customer', + 'customer.user', + 'customer.user.profilePicture', + 'customer.cards', + 'customer.cards.account', + ], skip: (pageOptions.page - FIRST_PAGE) * pageOptions.size, take: pageOptions.size, }); } findJuniorById(juniorId: string, withGuardianRelation = false, guardianId?: string) { - const relations = ['customer', 'customer.user', 'theme', 'theme.avatar', 'customer.user.profilePicture']; + const relations = [ + 'customer', + 'customer.user', + 'theme', + 'theme.avatar', + 'customer.user.profilePicture', + 'customer.cards', + 'customer.cards.account', + ]; if (withGuardianRelation) { relations.push('guardian', 'guardian.customer', 'guardian.customer.user'); }