diff --git a/src/card/dtos/responses/child-transfer-item.response.dto.ts b/src/card/dtos/responses/child-transfer-item.response.dto.ts new file mode 100644 index 0000000..f0889d0 --- /dev/null +++ b/src/card/dtos/responses/child-transfer-item.response.dto.ts @@ -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; +} + diff --git a/src/card/dtos/responses/index.ts b/src/card/dtos/responses/index.ts index fcea24f..cbbb700 100644 --- a/src/card/dtos/responses/index.ts +++ b/src/card/dtos/responses/index.ts @@ -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'; diff --git a/src/card/dtos/responses/junior-home.response.dto.ts b/src/card/dtos/responses/junior-home.response.dto.ts new file mode 100644 index 0000000..feaf195 --- /dev/null +++ b/src/card/dtos/responses/junior-home.response.dto.ts @@ -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; + } +} + diff --git a/src/card/dtos/responses/paged-child-transfers.response.dto.ts b/src/card/dtos/responses/paged-child-transfers.response.dto.ts new file mode 100644 index 0000000..6a25f86 --- /dev/null +++ b/src/card/dtos/responses/paged-child-transfers.response.dto.ts @@ -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; + } +} + diff --git a/src/card/dtos/responses/paged-parent-transfers.response.dto.ts b/src/card/dtos/responses/paged-parent-transfers.response.dto.ts new file mode 100644 index 0000000..bd1ec6f --- /dev/null +++ b/src/card/dtos/responses/paged-parent-transfers.response.dto.ts @@ -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; + } +} + diff --git a/src/card/dtos/responses/parent-home.response.dto.ts b/src/card/dtos/responses/parent-home.response.dto.ts new file mode 100644 index 0000000..682b53d --- /dev/null +++ b/src/card/dtos/responses/parent-home.response.dto.ts @@ -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; + } +} + diff --git a/src/card/dtos/responses/parent-transfer-item.response.dto.ts b/src/card/dtos/responses/parent-transfer-item.response.dto.ts new file mode 100644 index 0000000..e39c455 --- /dev/null +++ b/src/card/dtos/responses/parent-transfer-item.response.dto.ts @@ -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; +} + diff --git a/src/card/repositories/transaction.repository.ts b/src/card/repositories/transaction.repository.ts index 58ad8c5..118598d 100644 --- a/src/card/repositories/transaction.repository.ts +++ b/src/card/repositories/transaction.repository.ts @@ -144,4 +144,28 @@ export class TransactionRepository { .andWhere('parentCards.customerId = :guardianCustomerId', { guardianCustomerId }) .getCount(); } + + findTransfersToJunior(juniorId: string, skip: number, take: number): Promise { + 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 { + 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(); + } } diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index 50faf9b..a215c09 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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; diff --git a/src/guardian/controllers/guardian-transactions.controller.ts b/src/guardian/controllers/guardian-transactions.controller.ts index 0e76290..d0d5dfb 100644 --- a/src/guardian/controllers/guardian-transactions.controller.ts +++ b/src/guardian/controllers/guardian-transactions.controller.ts @@ -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); } } diff --git a/src/guardian/services/guardian-transactions.service.ts b/src/guardian/services/guardian-transactions.service.ts index a31b7c2..1a5dea5 100644 --- a/src/guardian/services/guardian-transactions.service.ts +++ b/src/guardian/services/guardian-transactions.service.ts @@ -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 { + async getHome(guardianId: string, size: number): Promise { 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 { - return this.transactionService.getParentTransactionsPaginated(guardianId, page, size, type); + ): Promise { + return this.transactionService.getParentTransfersPaginated(guardianId, page, size); } } diff --git a/src/junior/controllers/junior.controller.ts b/src/junior/controllers/junior.controller.ts index d3a472a..f02c769 100644 --- a/src/junior/controllers/junior.controller.ts +++ b/src/junior/controllers/junior.controller.ts @@ -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); + } } diff --git a/src/junior/services/junior.service.ts b/src/junior/services/junior.service.ts index d2e9961..203ce1f 100644 --- a/src/junior/services/junior.service.ts +++ b/src/junior/services/junior.service.ts @@ -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 { + 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 { + 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(