Merge pull request #36 from HamzaSha1/ZOD-309-child-transaction-history-parent-→-child-transfers

ZOD-309-child-transaction-history-parent-→-child-transfers
This commit is contained in:
abdalhamid99
2025-10-16 12:26:50 +03:00
committed by GitHub
13 changed files with 331 additions and 22 deletions

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
export class ChildTransferItemDto {
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
date!: Date;
@ApiProperty({ example: 50.0 })
amount!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiProperty({ example: 'You received {{amount}} {{currency}} from your parent.' })
message!: string;
}

View File

@ -4,3 +4,9 @@ export * from './child-card.response.dto';
export * from './transaction-item.response.dto';
export * from './guardian-home.response.dto';
export * from './paged-transactions.response.dto';
export * from './parent-transfer-item.response.dto';
export * from './parent-home.response.dto';
export * from './paged-parent-transfers.response.dto';
export * from './child-transfer-item.response.dto';
export * from './junior-home.response.dto';
export * from './paged-child-transfers.response.dto';

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { ChildTransferItemDto } from './child-transfer-item.response.dto';
export class JuniorHomeResponseDto {
@ApiProperty({ example: 500.0 })
availableBalance!: number;
@ApiProperty({ type: [ChildTransferItemDto] })
recentTransfers!: ChildTransferItemDto[];
constructor(availableBalance: number, recentTransfers: ChildTransferItemDto[]) {
this.availableBalance = availableBalance;
this.recentTransfers = recentTransfers;
}
}

View File

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { ChildTransferItemDto } from './child-transfer-item.response.dto';
export class PagedChildTransfersResponseDto {
@ApiProperty({ type: [ChildTransferItemDto] })
items!: ChildTransferItemDto[];
@ApiProperty({ example: 1 })
page!: number;
@ApiProperty({ example: 10 })
size!: number;
@ApiProperty({ example: 20 })
total!: number;
@ApiProperty({ example: true })
hasMore!: boolean;
constructor(
items: ChildTransferItemDto[],
page: number,
size: number,
total: number,
) {
this.items = items;
this.page = page;
this.size = size;
this.total = total;
this.hasMore = page * size < total;
}
}

View File

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { ParentTransferItemDto } from './parent-transfer-item.response.dto';
export class PagedParentTransfersResponseDto {
@ApiProperty({ type: [ParentTransferItemDto] })
items!: ParentTransferItemDto[];
@ApiProperty({ example: 1 })
page!: number;
@ApiProperty({ example: 10 })
size!: number;
@ApiProperty({ example: 45 })
total!: number;
@ApiProperty({ example: true })
hasMore!: boolean;
constructor(
items: ParentTransferItemDto[],
page: number,
size: number,
total: number,
) {
this.items = items;
this.page = page;
this.size = size;
this.total = total;
this.hasMore = page * size < total;
}
}

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { ParentTransferItemDto } from './parent-transfer-item.response.dto';
export class ParentHomeResponseDto {
@ApiProperty({ example: 2000.0 })
availableBalance!: number;
@ApiProperty({ type: [ParentTransferItemDto] })
recentTransfers!: ParentTransferItemDto[];
constructor(availableBalance: number, recentTransfers: ParentTransferItemDto[]) {
this.availableBalance = availableBalance;
this.recentTransfers = recentTransfers;
}
}

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
export class ParentTransferItemDto {
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
date!: Date;
@ApiProperty({ example: 50.0 })
amount!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiProperty({ example: 'Ahmed Ali' })
childName!: string;
}

View File

@ -144,4 +144,28 @@ export class TransactionRepository {
.andWhere('parentCards.customerId = :guardianCustomerId', { guardianCustomerId })
.getCount();
}
findTransfersToJunior(juniorId: string, skip: number, take: number): Promise<Transaction[]> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.card', 'card')
.innerJoinAndSelect('card.account', 'account')
.where('card.customerId = :juniorId', { juniorId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.orderBy('tx.transactionDate', 'DESC')
.skip(skip)
.take(take)
.getMany();
}
countTransfersToJunior(juniorId: string): Promise<number> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoin('tx.card', 'card')
.where('card.customerId = :juniorId', { juniorId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.getCount();
}
}

View File

@ -11,7 +11,14 @@ import { CustomerType, TransactionType } from '../enums';
import { TransactionRepository } from '../repositories/transaction.repository';
import { AccountService } from './account.service';
import { CardService } from './card.service';
import { TransactionItemResponseDto, PagedTransactionsResponseDto } from '../dtos/responses';
import {
TransactionItemResponseDto,
PagedTransactionsResponseDto,
ParentTransferItemDto,
PagedParentTransfersResponseDto,
ChildTransferItemDto,
PagedChildTransfersResponseDto,
} from '../dtos/responses';
import { ParentTransactionType } from '../enums';
@Injectable()
@ -182,6 +189,68 @@ export class TransactionService {
return new PagedTransactionsResponseDto(mapped, page, size, total);
}
async getParentTransfersOnly(guardianCustomerId: string, page: number, size: number): Promise<ParentTransferItemDto[]> {
const skip = (page - 1) * size;
const transfers = await this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size);
return transfers.map((t) => this.mapToParentTransferItem(t));
}
async getParentTransfersPaginated(
guardianCustomerId: string,
page: number,
size: number,
): Promise<PagedParentTransfersResponseDto> {
const skip = (page - 1) * size;
const [transfers, total] = await Promise.all([
this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size),
this.transactionRepository.countParentTransfers(guardianCustomerId),
]);
const items = transfers.map((t) => this.mapToParentTransferItem(t));
return new PagedParentTransfersResponseDto(items, page, size, total);
}
async getChildTransfers(juniorId: string, page: number, size: number): Promise<ChildTransferItemDto[]> {
const skip = (page - 1) * size;
const transfers = await this.transactionRepository.findTransfersToJunior(juniorId, skip, size);
return transfers.map((t) => this.mapToChildTransferItem(t));
}
async getChildTransfersPaginated(
juniorId: string,
page: number,
size: number,
): Promise<PagedChildTransfersResponseDto> {
const skip = (page - 1) * size;
const [transfers, total] = await Promise.all([
this.transactionRepository.findTransfersToJunior(juniorId, skip, size),
this.transactionRepository.countTransfersToJunior(juniorId),
]);
const items = transfers.map((t) => this.mapToChildTransferItem(t));
return new PagedChildTransfersResponseDto(items, page, size, total);
}
private mapToParentTransferItem(t: Transaction): ParentTransferItemDto {
const child = t.card?.customer;
const currency = t.transactionCurrency === '682' ? 'SAR' : t.transactionCurrency;
return {
date: t.transactionDate,
amount: Math.abs(t.transactionAmount),
currency,
childName: child ? `${child.firstName} ${child.lastName}` : 'Child',
};
}
private mapToChildTransferItem(t: Transaction): ChildTransferItemDto {
const amount = Math.abs(t.transactionAmount);
const currency = t.transactionCurrency === '682' ? 'SAR' : t.transactionCurrency;
return {
date: t.transactionDate,
amount,
currency,
message: `You received {{amount}} {{currency}} from your parent.`,
};
}
private mapParentItem(t: Transaction): TransactionItemResponseDto {
const dto = new TransactionItemResponseDto();
dto.date = t.transactionDate;

View File

@ -6,8 +6,7 @@ import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard, RolesGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { GuardianHomeResponseDto, PagedTransactionsResponseDto } from '~/card/dtos/responses';
import { ParentTransactionType } from '~/card/enums';
import { ParentHomeResponseDto, PagedParentTransfersResponseDto } from '~/card/dtos/responses';
import { GuardianTransactionsService } from '../services';
@ -22,7 +21,7 @@ export class GuardianTransactionsController {
@Get('home')
@ApiQuery({ name: 'size', required: false, type: Number, example: 5 })
@ApiDataResponse(GuardianHomeResponseDto)
@ApiDataResponse(ParentHomeResponseDto)
async getHome(
@AuthenticatedUser() user: IJwtPayload,
@Query('size') size?: number,
@ -32,20 +31,18 @@ export class GuardianTransactionsController {
return ResponseFactory.data(res);
}
@Get('transactions')
@Get('transfers')
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
@ApiQuery({ name: 'size', required: false, type: Number, example: 10 })
@ApiQuery({ name: 'type', required: false, enum: ParentTransactionType })
@ApiDataResponse(PagedTransactionsResponseDto)
async getTransactions(
@ApiDataResponse(PagedParentTransfersResponseDto)
async getTransfers(
@AuthenticatedUser() user: IJwtPayload,
@Query('page') page?: number,
@Query('size') size?: number,
@Query('type') type?: ParentTransactionType,
) {
const pageNum = Math.max(1, Number(page) || 1);
const pageSize = Math.max(1, Math.min(Number(size) || 10, 50));
const res = await this.guardianTxService.getTransactions(user.sub, pageNum, pageSize, type);
const res = await this.guardianTxService.getTransfers(user.sub, pageNum, pageSize);
return ResponseFactory.data(res);
}
}

View File

@ -1,8 +1,6 @@
import { Injectable } from '@nestjs/common';
import { CustomerService } from '~/customer/services';
import { GuardianHomeResponseDto, PagedTransactionsResponseDto } from '~/card/dtos/responses';
import { TransactionItemResponseDto } from '~/card/dtos/responses';
import { ParentTransactionType } from '~/card/enums';
import { ParentHomeResponseDto, PagedParentTransfersResponseDto } from '~/card/dtos/responses';
import { TransactionService } from '~/card/services/transaction.service';
@Injectable()
@ -12,7 +10,7 @@ export class GuardianTransactionsService {
private readonly transactionService: TransactionService,
) {}
async getHome(guardianId: string, size: number): Promise<GuardianHomeResponseDto> {
async getHome(guardianId: string, size: number): Promise<ParentHomeResponseDto> {
const parent = await this.customerService.findCustomerById(guardianId);
const primaryCard = parent.cards?.[0];
@ -27,18 +25,17 @@ export class GuardianTransactionsService {
}
}
const items: TransactionItemResponseDto[] = await this.transactionService.getParentConsolidated(guardianId, 1, size);
const recentTransfers = await this.transactionService.getParentTransfersOnly(guardianId, 1, size);
return new GuardianHomeResponseDto(availableBalance, items);
return new ParentHomeResponseDto(availableBalance, recentTransfers);
}
async getTransactions(
async getTransfers(
guardianId: string,
page: number,
size: number,
type?: ParentTransactionType,
): Promise<PagedTransactionsResponseDto> {
return this.transactionService.getParentTransactionsPaginated(guardianId, page, size, type);
): Promise<PagedParentTransfersResponseDto> {
return this.transactionService.getParentTransfersPaginated(guardianId, page, size);
}
}

View File

@ -11,7 +11,7 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiTags, ApiQuery } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser, Public } from '~/common/decorators';
@ -33,6 +33,7 @@ import {
TransferToJuniorResponseDto,
} from '../dtos/response';
import { WeeklySummaryResponseDto } from '../dtos/response/weekly-summary.response.dto';
import { JuniorHomeResponseDto, PagedChildTransfersResponseDto } from '~/card/dtos/responses';
import { JuniorService } from '../services';
@Controller('juniors')
@ -157,4 +158,37 @@ export class JuniorController {
const summary = await this.juniorService.getWeeklySummary(juniorId, user.sub);
return ResponseFactory.data(summary);
}
@Get(':juniorId/home')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN)
@ApiQuery({ name: 'size', required: false, type: Number, example: 5 })
@ApiDataResponse(JuniorHomeResponseDto)
async getJuniorHome(
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
@AuthenticatedUser() user: IJwtPayload,
@Query('size') size?: number,
) {
const limit = Math.max(1, Math.min(Number(size) || 5, 20));
const res = await this.juniorService.getJuniorHome(juniorId, user.sub, limit);
return ResponseFactory.data(res);
}
@Get(':juniorId/transfers')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN)
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
@ApiQuery({ name: 'size', required: false, type: Number, example: 10 })
@ApiDataResponse(PagedChildTransfersResponseDto)
async getJuniorTransfers(
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
@AuthenticatedUser() user: IJwtPayload,
@Query('page') page?: number,
@Query('size') size?: number,
) {
const pageNum = Math.max(1, Number(page) || 1);
const pageSize = Math.max(1, Math.min(Number(size) || 10, 50));
const res = await this.juniorService.getJuniorTransfers(juniorId, user.sub, pageNum, pageSize);
return ResponseFactory.data(res);
}
}

View File

@ -2,7 +2,7 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
import { Transactional } from 'typeorm-transactional';
import { Roles } from '~/auth/enums';
import { CardService } from '~/card/services';
import { CardService, TransactionService } from '~/card/services';
import { NeoLeapService } from '~/common/modules/neoleap/services';
import { PageOptionsRequestDto } from '~/core/dtos';
import { setIf } from '~/core/utils';
@ -20,6 +20,7 @@ import {
import { Junior } from '../entities';
import { JuniorRepository } from '../repositories';
import { QrcodeService } from './qrcode.service';
import { JuniorHomeResponseDto, PagedChildTransfersResponseDto } from '~/card/dtos/responses';
@Injectable()
export class JuniorService {
@ -34,6 +35,7 @@ export class JuniorService {
private readonly qrCodeService: QrcodeService,
private readonly neoleapService: NeoLeapService,
private readonly cardService: CardService,
private readonly transactionService: TransactionService,
) {}
@Transactional()
@ -221,6 +223,56 @@ export class JuniorService {
return this.cardService.getWeeklySummary(juniorId);
}
async getJuniorHome(juniorId: string, userId: string, size: number): Promise<JuniorHomeResponseDto> {
this.logger.log(`Getting home for junior ${juniorId}`);
// Check if user is the junior themselves or their guardian
let junior: Junior | null;
if (juniorId === userId) {
// User is the junior accessing their own home
junior = await this.findJuniorById(juniorId, false);
} else {
// User might be the guardian accessing junior's home
junior = await this.findJuniorById(juniorId, false, userId);
}
if (!junior) {
throw new BadRequestException('JUNIOR.NOT_FOUND');
}
const card = junior.customer?.cards?.[0];
const availableBalance = card ? Math.min(card.limit, card.account.balance) : 0;
const recentTransfers = await this.transactionService.getChildTransfers(juniorId, 1, size);
return new JuniorHomeResponseDto(availableBalance, recentTransfers);
}
async getJuniorTransfers(
juniorId: string,
userId: string,
page: number,
size: number,
): Promise<PagedChildTransfersResponseDto> {
this.logger.log(`Getting transfers for junior ${juniorId}`);
// Check if user is the junior themselves or their guardian
let junior: Junior | null;
if (juniorId === userId) {
// User is the junior accessing their own transfers
junior = await this.findJuniorById(juniorId, false);
} else {
// User might be the guardian accessing junior's transfers
junior = await this.findJuniorById(juniorId, false, userId);
}
if (!junior) {
throw new BadRequestException('JUNIOR.NOT_FOUND');
}
return this.transactionService.getChildTransfersPaginated(juniorId, page, size);
}
private async prepareJuniorImages(juniors: Junior[]) {
this.logger.log(`Preparing junior images`);
await Promise.all(