fix: calculating child and parent balance

This commit is contained in:
Abdalhameed Ahmad
2025-09-09 20:31:48 +03:00
parent e1f50decfa
commit 039c95aa56
12 changed files with 83 additions and 26 deletions

View File

@ -43,6 +43,13 @@ export class CardResponseDto {
}) })
balance!: number; 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) { constructor(card: Card) {
this.id = card.id; this.id = card.id;
this.firstSixDigits = card.firstSixDigits; this.firstSixDigits = card.firstSixDigits;
@ -52,5 +59,6 @@ export class CardResponseDto {
this.statusDescription = CardStatusDescriptionMapper[card.statusDescription][UserLocale.ENGLISH].description; this.statusDescription = CardStatusDescriptionMapper[card.statusDescription][UserLocale.ENGLISH].description;
this.balance = this.balance =
card.customerType === CustomerType.CHILD ? Math.min(card.limit, card.account.balance) : card.account.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;
} }
} }

View File

@ -25,6 +25,9 @@ export class Account {
@Column('decimal', { precision: 10, scale: 2, default: 0.0, name: 'balance' }) @Column('decimal', { precision: 10, scale: 2, default: 0.0, name: 'balance' })
balance!: number; balance!: number;
@Column('decimal', { precision: 10, scale: 2, default: 0.0, name: 'reserved_balance' })
reservedBalance!: number;
@OneToMany(() => Card, (card) => card.account, { cascade: true }) @OneToMany(() => Card, (card) => card.account, { cascade: true })
cards!: Card[]; cards!: Card[];

View File

@ -55,4 +55,12 @@ export class AccountRepository {
decreaseAccountBalance(accountReference: string, amount: number) { decreaseAccountBalance(accountReference: string, amount: number) {
return this.accountRepository.decrement({ accountReference }, 'balance', amount); 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);
}
} }

View File

@ -14,13 +14,14 @@ export class CardRepository {
accountId: string, accountId: string,
card: CreateApplicationResponse, card: CreateApplicationResponse,
cardColor?: CardColors, cardColor?: CardColors,
isChildCard = false,
): Promise<Card> { ): Promise<Card> {
return this.cardRepository.save( return this.cardRepository.save(
this.cardRepository.create({ this.cardRepository.create({
customerId: customerId, customerId: customerId,
expiry: card.expiryDate, expiry: card.expiryDate,
cardReference: card.cardId, cardReference: card.cardId,
customerType: CustomerType.PARENT, customerType: isChildCard ? CustomerType.CHILD : CustomerType.PARENT,
firstSixDigits: card.firstSixDigits, firstSixDigits: card.firstSixDigits,
lastFourDigits: card.lastFourDigits, lastFourDigits: card.lastFourDigits,
color: cardColor ? cardColor : CardColors.DEEP_MAGENTA, color: cardColor ? cardColor : CardColors.DEEP_MAGENTA,

View File

@ -1,6 +1,5 @@
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 {
@ -85,16 +84,4 @@ 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

@ -49,9 +49,11 @@ export class AccountService {
async decreaseAccountBalance(accountReference: string, amount: number) { async decreaseAccountBalance(accountReference: string, amount: number) {
const account = await this.getAccountByReferenceNumber(accountReference); const account = await this.getAccountByReferenceNumber(accountReference);
/** /**
*
* While there is no need to check for insufficient balance because this is a webhook handler, * 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) { if (account.balance < amount) {
@ -61,6 +63,17 @@ export class AccountService {
return this.accountRepository.decreaseAccountBalance(accountReference, amount); 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 //THIS IS A MOCK FUNCTION FOR TESTING PURPOSES ONLY
async fundIban(iban: string, amount: number) { async fundIban(iban: string, amount: number) {
const account = await this.getAccountByIban(iban); const account = await this.getAccountByIban(iban);

View File

@ -49,6 +49,7 @@ export class CardService {
parentCustomer.cards[0].account.id, parentCustomer.cards[0].account.id,
data, data,
cardColor, cardColor,
true,
); );
return this.getCardById(createdCard.id); return this.getCardById(createdCard.id);
@ -87,6 +88,7 @@ export class CardService {
if (!card) { if (!card) {
throw new BadRequestException('CARD.NOT_FOUND'); throw new BadRequestException('CARD.NOT_FOUND');
} }
return card; return card;
} }
@ -119,9 +121,8 @@ 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) { if (amount > card.account.balance - card.account.reservedBalance) {
throw new BadRequestException('CARD.INSUFFICIENT_BALANCE'); throw new BadRequestException('CARD.INSUFFICIENT_BALANCE');
} }
@ -129,6 +130,7 @@ export class CardService {
await Promise.all([ await Promise.all([
this.neoleapService.updateCardControl(card.cardReference, finalAmount.toNumber()), this.neoleapService.updateCardControl(card.cardReference, finalAmount.toNumber()),
this.updateCardLimit(card.id, finalAmount.toNumber()), this.updateCardLimit(card.id, finalAmount.toNumber()),
this.accountService.increaseReservedBalance(card.account, amount),
this.transactionService.createInternalChildTransaction(card.id, amount), this.transactionService.createInternalChildTransaction(card.id, amount),
]); ]);

View File

@ -5,8 +5,8 @@ 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 { CustomerType } from '../enums';
import { TransactionRepository } from '../repositories/transaction.repository'; import { TransactionRepository } from '../repositories/transaction.repository';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
import { CardService } from './card.service'; import { CardService } from './card.service';
@ -31,7 +31,14 @@ export class TransactionService {
const transaction = await this.transactionRepository.createCardTransaction(card, body); const transaction = await this.transactionRepository.createCardTransaction(card, body);
const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees); 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; return transaction;
} }
@ -58,11 +65,6 @@ 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

@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddReservationAmountToAccountEntity1757433339849 implements MigrationInterface {
name = 'AddReservationAmountToAccountEntity1757433339849';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "accounts" ADD "reserved_balance" numeric(10,2) NOT NULL DEFAULT '0'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "accounts" DROP COLUMN "reserved_balance"`);
}
}

View File

@ -2,3 +2,4 @@ export * from './1754913378460-initial-migration';
export * from './1754915164809-create-neoleap-related-entities'; export * from './1754915164809-create-neoleap-related-entities';
export * from './1754915164810-seed-default-avatar'; export * from './1754915164810-seed-default-avatar';
export * from './1757349525708-create-money-requests-table'; export * from './1757349525708-create-money-requests-table';
export * from './1757433339849-add-reservation-amount-to-account-entity';

View File

@ -32,7 +32,11 @@ export class JuniorResponseDto {
@ApiProperty({ type: DocumentMetaResponseDto }) @ApiProperty({ type: DocumentMetaResponseDto })
profilePicture!: DocumentMetaResponseDto | null; profilePicture!: DocumentMetaResponseDto | null;
@ApiProperty({ example: 2000.0, description: 'The available balance' })
availableBalance!: number;
constructor(junior: Junior) { constructor(junior: Junior) {
const card = junior.customer.cards?.[0];
this.id = junior.id; this.id = junior.id;
this.firstName = junior.customer.firstName; this.firstName = junior.customer.firstName;
this.lastName = junior.customer.lastName; this.lastName = junior.customer.lastName;
@ -41,6 +45,7 @@ export class JuniorResponseDto {
this.dateOfBirth = junior.customer.dateOfBirth; this.dateOfBirth = junior.customer.dateOfBirth;
this.relationship = junior.relationship; this.relationship = junior.relationship;
this.guardianRelationship = GuardianRelationship[junior.relationship]; this.guardianRelationship = GuardianRelationship[junior.relationship];
this.availableBalance = card ? Math.min(card.limit, card.account.balance) : 0;
this.profilePicture = junior.customer.user.profilePicture this.profilePicture = junior.customer.user.profilePicture
? new DocumentMetaResponseDto(junior.customer.user.profilePicture) ? new DocumentMetaResponseDto(junior.customer.user.profilePicture)
: null; : null;

View File

@ -13,14 +13,28 @@ export class JuniorRepository {
findJuniorsByGuardianId(guardianId: string, pageOptions: PageOptionsRequestDto) { findJuniorsByGuardianId(guardianId: string, pageOptions: PageOptionsRequestDto) {
return this.juniorRepository.findAndCount({ return this.juniorRepository.findAndCount({
where: { guardianId }, 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, skip: (pageOptions.page - FIRST_PAGE) * pageOptions.size,
take: pageOptions.size, take: pageOptions.size,
}); });
} }
findJuniorById(juniorId: string, withGuardianRelation = false, guardianId?: string) { 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) { if (withGuardianRelation) {
relations.push('guardian', 'guardian.customer', 'guardian.customer.user'); relations.push('guardian', 'guardian.customer', 'guardian.customer.user');
} }