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..fcea24f 100644 --- a/src/card/dtos/responses/index.ts +++ b/src/card/dtos/responses/index.ts @@ -1,3 +1,6 @@ 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'; 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/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..58ad8c5 100644 --- a/src/card/repositories/transaction.repository.ts +++ b/src/card/repositories/transaction.repository.ts @@ -96,4 +96,52 @@ 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(); + } } 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..50faf9b 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -7,10 +7,12 @@ 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 } from '../dtos/responses'; +import { ParentTransactionType } from '../enums'; @Injectable() export class TransactionService { @@ -113,4 +115,96 @@ 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); + } + + 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..0e76290 --- /dev/null +++ b/src/guardian/controllers/guardian-transactions.controller.ts @@ -0,0 +1,53 @@ +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 { GuardianHomeResponseDto, PagedTransactionsResponseDto } from '~/card/dtos/responses'; +import { ParentTransactionType } from '~/card/enums'; +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(GuardianHomeResponseDto) + 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('transactions') + @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( + @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); + 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..a31b7c2 --- /dev/null +++ b/src/guardian/services/guardian-transactions.service.ts @@ -0,0 +1,45 @@ +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 { 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 items: TransactionItemResponseDto[] = await this.transactionService.getParentConsolidated(guardianId, 1, size); + + return new GuardianHomeResponseDto(availableBalance, items); + } + + async getTransactions( + guardianId: string, + page: number, + size: number, + type?: ParentTransactionType, + ): Promise { + return this.transactionService.getParentTransactionsPaginated(guardianId, page, size, type); + } +} + + 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'