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/guardian-home.response.dto.ts b/src/card/dtos/responses/guardian-home.response.dto.ts new file mode 100644 index 0000000..bdf555d --- /dev/null +++ b/src/card/dtos/responses/guardian-home.response.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { TransactionItemResponseDto } from './transaction-item.response.dto'; + +export class GuardianHomeResponseDto { + @ApiProperty({ example: 2000.0 }) + availableBalance!: number; + + @ApiProperty({ type: [TransactionItemResponseDto] }) + recentTransactions!: TransactionItemResponseDto[]; + + constructor(availableBalance: number, recentTransactions: TransactionItemResponseDto[]) { + this.availableBalance = availableBalance; + this.recentTransactions = recentTransactions; + } +} + + diff --git a/src/card/dtos/responses/index.ts b/src/card/dtos/responses/index.ts index ae3310e..cbbb700 100644 --- a/src/card/dtos/responses/index.ts +++ b/src/card/dtos/responses/index.ts @@ -1,3 +1,12 @@ export * from './account-iban.response.dto'; export * from './card.response.dto'; 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/paged-transactions.response.dto.ts b/src/card/dtos/responses/paged-transactions.response.dto.ts new file mode 100644 index 0000000..cc38341 --- /dev/null +++ b/src/card/dtos/responses/paged-transactions.response.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { TransactionItemResponseDto } from './transaction-item.response.dto'; + +export class PagedTransactionsResponseDto { + @ApiProperty({ type: [TransactionItemResponseDto] }) + items!: TransactionItemResponseDto[]; + + @ApiProperty({ example: 1 }) + page!: number; + + @ApiProperty({ example: 10 }) + size!: number; + + @ApiProperty({ example: 45 }) + total!: number; + + @ApiProperty({ example: true }) + hasMore!: boolean; + + constructor( + items: TransactionItemResponseDto[], + 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/dtos/responses/transaction-item.response.dto.ts b/src/card/dtos/responses/transaction-item.response.dto.ts new file mode 100644 index 0000000..647da36 --- /dev/null +++ b/src/card/dtos/responses/transaction-item.response.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ParentTransactionType } from '~/card/enums'; + +export class TransactionItemResponseDto { + @ApiProperty() + date!: Date; + + @ApiProperty({ example: -50.0 }) + amountSigned!: number; + + @ApiProperty({ enum: ParentTransactionType }) + type!: ParentTransactionType; + + @ApiProperty({ description: 'Counterparty display name (child for transfer, source label for top-up)' }) + counterpartyName!: string; + + @ApiProperty({ nullable: true }) + counterpartyAccountMasked!: string | null; + + @ApiProperty({ required: false }) + childName?: string; +} + + diff --git a/src/card/enums/index.ts b/src/card/enums/index.ts index 16b52c2..9b25ef5 100644 --- a/src/card/enums/index.ts +++ b/src/card/enums/index.ts @@ -6,3 +6,4 @@ export * from './card-status.enum'; export * from './customer-type.enum'; export * from './transaction-scope.enum'; export * from './transaction-type.enum'; +export * from './parent-transaction-type.enum'; diff --git a/src/card/enums/parent-transaction-type.enum.ts b/src/card/enums/parent-transaction-type.enum.ts new file mode 100644 index 0000000..bed0941 --- /dev/null +++ b/src/card/enums/parent-transaction-type.enum.ts @@ -0,0 +1,6 @@ +export enum ParentTransactionType { + PARENT_TRANSFER = 'PARENT_TRANSFER', + PARENT_TOPUP = 'PARENT_TOPUP', +} + + diff --git a/src/card/repositories/index.ts b/src/card/repositories/index.ts index 8458740..a005850 100644 --- a/src/card/repositories/index.ts +++ b/src/card/repositories/index.ts @@ -1 +1,3 @@ export * from './card.repository'; +export * from './transaction.repository'; +export * from './account.repository'; diff --git a/src/card/repositories/transaction.repository.ts b/src/card/repositories/transaction.repository.ts index b8e84b8..118598d 100644 --- a/src/card/repositories/transaction.repository.ts +++ b/src/card/repositories/transaction.repository.ts @@ -96,4 +96,76 @@ export class TransactionRepository { .orderBy('transaction.transactionDate', 'DESC') .getMany(); } + + findParentTransfers(guardianCustomerId: string, skip: number, take: number): Promise { + return this.transactionRepository + .createQueryBuilder('tx') + .innerJoinAndSelect('tx.card', 'card') + .innerJoinAndSelect('card.customer', 'childCustomer') + .innerJoinAndSelect('card.account', 'account') + .where('card.parentId = :guardianCustomerId', { guardianCustomerId }) + .andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD }) + .andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL }) + .orderBy('tx.transactionDate', 'DESC') + .skip(skip) + .take(take) + .getMany(); + } + + findParentTopups(guardianCustomerId: string, skip: number, take: number): Promise { + return this.transactionRepository + .createQueryBuilder('tx') + .innerJoinAndSelect('tx.account', 'account') + .leftJoinAndSelect('account.cards', 'parentCards') + .where('tx.transactionScope = :scope', { scope: TransactionScope.ACCOUNT }) + .andWhere('parentCards.customerId = :guardianCustomerId', { guardianCustomerId }) + .orderBy('tx.transactionDate', 'DESC') + .skip(skip) + .take(take) + .getMany(); + } + + countParentTransfers(guardianCustomerId: string): Promise { + return this.transactionRepository + .createQueryBuilder('tx') + .innerJoin('tx.card', 'card') + .where('card.parentId = :guardianCustomerId', { guardianCustomerId }) + .andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD }) + .andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL }) + .getCount(); + } + + countParentTopups(guardianCustomerId: string): Promise { + return this.transactionRepository + .createQueryBuilder('tx') + .innerJoin('tx.account', 'account') + .leftJoin('account.cards', 'parentCards') + .where('tx.transactionScope = :scope', { scope: TransactionScope.ACCOUNT }) + .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/index.ts b/src/card/services/index.ts index ea35f0f..c03697d 100644 --- a/src/card/services/index.ts +++ b/src/card/services/index.ts @@ -1 +1,3 @@ export * from './card.service'; +export * from './transaction.service'; +export * from './account.service'; diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index 96237dd..a215c09 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -7,10 +7,19 @@ import { CardTransactionWebhookRequest, } from '~/common/modules/neoleap/dtos/requests'; import { Transaction } from '../entities/transaction.entity'; -import { CustomerType } from '../enums'; +import { CustomerType, TransactionType } from '../enums'; import { TransactionRepository } from '../repositories/transaction.repository'; import { AccountService } from './account.service'; import { CardService } from './card.service'; +import { + TransactionItemResponseDto, + PagedTransactionsResponseDto, + ParentTransferItemDto, + PagedParentTransfersResponseDto, + ChildTransferItemDto, + PagedChildTransfersResponseDto, +} from '../dtos/responses'; +import { ParentTransactionType } from '../enums'; @Injectable() export class TransactionService { @@ -113,4 +122,158 @@ export class TransactionService { return summary; } + + async getParentConsolidated( + guardianCustomerId: string, + page: number, + size: number, + ): Promise { + const skip = (page - 1) * size; + + const [transfers, topups] = await Promise.all([ + this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size), + this.transactionRepository.findParentTopups(guardianCustomerId, skip, size), + ]); + + const merged = [...transfers, ...topups].sort( + (a, b) => new Date(b.transactionDate).getTime() - new Date(a.transactionDate).getTime(), + ); + + const trimmed = merged.slice(0, size); + + return trimmed.map((t) => this.mapParentItem(t)); + } + + async getParentTransactionsPaginated( + guardianCustomerId: string, + page: number, + size: number, + type?: ParentTransactionType, + ): Promise { + const skip = (page - 1) * size; + + let transfers: Transaction[] = []; + let topups: Transaction[] = []; + let transferCount = 0; + let topupCount = 0; + + if (!type || type === ParentTransactionType.PARENT_TRANSFER) { + [transfers, transferCount] = await Promise.all([ + this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size), + this.transactionRepository.countParentTransfers(guardianCustomerId), + ]); + } + + if (!type || type === ParentTransactionType.PARENT_TOPUP) { + [topups, topupCount] = await Promise.all([ + this.transactionRepository.findParentTopups(guardianCustomerId, skip, size), + this.transactionRepository.countParentTopups(guardianCustomerId), + ]); + } + + const total = transferCount + topupCount; + + if (type) { + const items = type === ParentTransactionType.PARENT_TRANSFER ? transfers : topups; + const mapped = items.map((t) => this.mapParentItem(t)); + return new PagedTransactionsResponseDto(mapped, page, size, total); + } + + const merged = [...transfers, ...topups].sort( + (a, b) => new Date(b.transactionDate).getTime() - new Date(a.transactionDate).getTime(), + ); + + const paginated = merged.slice(0, size); + const mapped = paginated.map((t) => this.mapParentItem(t)); + + 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; + + if (t.transactionType === TransactionType.INTERNAL) { + dto.type = ParentTransactionType.PARENT_TRANSFER; + dto.amountSigned = -Math.abs(t.transactionAmount); + const child = t.card?.customer; + dto.counterpartyName = child ? `${child.firstName} ${child.lastName}` : 'Child'; + dto.childName = dto.counterpartyName; + dto.counterpartyAccountMasked = t.card?.account?.accountReference + ? `****${t.card.account.accountReference.slice(-4)}` + : null; + return dto; + } + + dto.type = ParentTransactionType.PARENT_TOPUP; + const settlement = Number(t.settlementAmount ?? 0); + const txn = Number(t.transactionAmount ?? 0); + const creditAmount = settlement > 0 ? settlement : txn; + dto.amountSigned = Math.abs(Number.isFinite(creditAmount) ? creditAmount : 0); + dto.counterpartyName = 'Top-up'; + dto.counterpartyAccountMasked = t.accountReference ? `****${t.accountReference.slice(-4)}` : null; + return dto; + } } diff --git a/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts index 06e1437..275a53a 100644 --- a/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts +++ b/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts @@ -56,12 +56,12 @@ export class AccountTransactionWebhookRequest { @ApiProperty({ example: '682' }) currency!: string; - @Expose() + @Expose({ name: 'Date' }) @IsString() @ApiProperty({ name: 'Date', example: '20241112' }) date!: string; - @Expose() + @Expose({ name: 'Time' }) @IsString() @ApiProperty({ name: 'Time', example: '125340' }) time!: string; diff --git a/src/guardian/controllers/guardian-transactions.controller.ts b/src/guardian/controllers/guardian-transactions.controller.ts new file mode 100644 index 0000000..d0d5dfb --- /dev/null +++ b/src/guardian/controllers/guardian-transactions.controller.ts @@ -0,0 +1,50 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags, ApiQuery } from '@nestjs/swagger'; +import { Roles } from '~/auth/enums'; +import { IJwtPayload } from '~/auth/interfaces'; +import { AllowedRoles, AuthenticatedUser } from '~/common/decorators'; +import { AccessTokenGuard, RolesGuard } from '~/common/guards'; +import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; +import { ResponseFactory } from '~/core/utils'; +import { ParentHomeResponseDto, PagedParentTransfersResponseDto } from '~/card/dtos/responses'; +import { GuardianTransactionsService } from '../services'; + + +@Controller('guardians/me') +@ApiTags('Guardians') +@ApiBearerAuth() +@ApiLangRequestHeader() +@UseGuards(AccessTokenGuard, RolesGuard) +@AllowedRoles(Roles.GUARDIAN) +export class GuardianTransactionsController { + constructor(private readonly guardianTxService: GuardianTransactionsService) {} + + @Get('home') + @ApiQuery({ name: 'size', required: false, type: Number, example: 5 }) + @ApiDataResponse(ParentHomeResponseDto) + async getHome( + @AuthenticatedUser() user: IJwtPayload, + @Query('size') size?: number, + ) { + const limit = Math.max(1, Math.min(Number(size) || 5, 20)); + const res = await this.guardianTxService.getHome(user.sub, limit); + return ResponseFactory.data(res); + } + + @Get('transfers') + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) + @ApiQuery({ name: 'size', required: false, type: Number, example: 10 }) + @ApiDataResponse(PagedParentTransfersResponseDto) + async getTransfers( + @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.guardianTxService.getTransfers(user.sub, pageNum, pageSize); + return ResponseFactory.data(res); + } +} + + diff --git a/src/guardian/controllers/index.ts b/src/guardian/controllers/index.ts new file mode 100644 index 0000000..7f4401b --- /dev/null +++ b/src/guardian/controllers/index.ts @@ -0,0 +1,3 @@ +export * from './guardian-transactions.controller'; + + diff --git a/src/guardian/guardian.module.ts b/src/guardian/guardian.module.ts index c80c5c1..671de6d 100644 --- a/src/guardian/guardian.module.ts +++ b/src/guardian/guardian.module.ts @@ -1,12 +1,18 @@ import { Module } from '@nestjs/common'; +import { forwardRef } from '@nestjs/common'; +import { CustomerModule } from '~/customer/customer.module'; +import { CardModule } from '~/card/card.module'; +import { GuardianTransactionsController } from './controllers/guardian-transactions.controller'; +import { GuardianTransactionsService } from './services/guardian-transactions.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Guardian } from './entities/guradian.entity'; import { GuardianRepository } from './repositories'; import { GuardianService } from './services'; @Module({ - providers: [GuardianService, GuardianRepository], - imports: [TypeOrmModule.forFeature([Guardian])], + providers: [GuardianService, GuardianRepository, GuardianTransactionsService], + controllers: [GuardianTransactionsController], + imports: [TypeOrmModule.forFeature([Guardian]), forwardRef(() => CustomerModule), forwardRef(() => CardModule)], exports: [GuardianService], }) export class GuardianModule {} diff --git a/src/guardian/services/guardian-transactions.service.ts b/src/guardian/services/guardian-transactions.service.ts new file mode 100644 index 0000000..1a5dea5 --- /dev/null +++ b/src/guardian/services/guardian-transactions.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { CustomerService } from '~/customer/services'; +import { ParentHomeResponseDto, PagedParentTransfersResponseDto } from '~/card/dtos/responses'; +import { TransactionService } from '~/card/services/transaction.service'; + +@Injectable() +export class GuardianTransactionsService { + constructor( + private readonly customerService: CustomerService, + private readonly transactionService: TransactionService, + ) {} + + async getHome(guardianId: string, size: number): Promise { + const parent = await this.customerService.findCustomerById(guardianId); + const primaryCard = parent.cards?.[0]; + + let availableBalance = 0; + if (primaryCard) { + const hasLimit = typeof primaryCard.limit === 'number' && !Number.isNaN(primaryCard.limit); + const hasBalance = primaryCard.account && typeof primaryCard.account.balance === 'number'; + if (hasLimit && hasBalance && primaryCard.limit > 0) { + availableBalance = Math.min(primaryCard.limit, primaryCard.account.balance); + } else if (hasBalance) { + availableBalance = primaryCard.account.balance; + } + } + + const recentTransfers = await this.transactionService.getParentTransfersOnly(guardianId, 1, size); + + return new ParentHomeResponseDto(availableBalance, recentTransfers); + } + + async getTransfers( + guardianId: string, + page: number, + size: number, + ): Promise { + return this.transactionService.getParentTransfersPaginated(guardianId, page, size); + } +} + + diff --git a/src/guardian/services/index.ts b/src/guardian/services/index.ts index 5cdfe9f..69996e8 100644 --- a/src/guardian/services/index.ts +++ b/src/guardian/services/index.ts @@ -1 +1,2 @@ -export * from './guardian.service'; +export * from './guardian.service' +export * from './guardian-transactions.service' 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(