diff --git a/src/card/dtos/responses/index.ts b/src/card/dtos/responses/index.ts index cbbb700..5aded1d 100644 --- a/src/card/dtos/responses/index.ts +++ b/src/card/dtos/responses/index.ts @@ -10,3 +10,6 @@ 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'; +export * from './spending-history-item.response.dto'; +export * from './spending-history.response.dto'; +export * from './transaction-detail.response.dto'; diff --git a/src/card/dtos/responses/spending-history-item.response.dto.ts b/src/card/dtos/responses/spending-history-item.response.dto.ts new file mode 100644 index 0000000..b9312ba --- /dev/null +++ b/src/card/dtos/responses/spending-history-item.response.dto.ts @@ -0,0 +1,58 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transaction } from '~/card/entities/transaction.entity'; + +export class SpendingHistoryItemDto { + @ApiProperty({ example: '2025-10-14T09:53:40.000Z' }) + date!: Date; + + @ApiProperty({ example: 50.5 }) + amount!: number; + + @ApiProperty({ example: 'SAR' }) + currency!: string; + + @ApiPropertyOptional({ example: 'Shopping' }) + category!: string | null; + + @ApiPropertyOptional({ example: 'Target Store' }) + merchantName!: string | null; + + @ApiPropertyOptional({ example: 'Riyadh' }) + merchantCity!: string | null; + + @ApiProperty({ example: '277012*****3456' }) + cardMasked!: string; + + @ApiProperty() + transactionId!: string; + + constructor(transaction: Transaction) { + this.date = transaction.transactionDate; + this.amount = transaction.transactionAmount; + this.currency = transaction.transactionCurrency === '682' ? 'SAR' : transaction.transactionCurrency; + this.category = this.mapMccToCategory(transaction.merchantCategoryCode); + this.merchantName = transaction.merchantName; + this.merchantCity = transaction.merchantCity; + this.cardMasked = transaction.cardMaskedNumber; + this.transactionId = transaction.id; + } + + private mapMccToCategory(mcc: string | null): string { + if (!mcc) return 'Other'; + + const mccCode = mcc; + + // Map MCC codes to categories + if (mccCode >= '5200' && mccCode <= '5599') return 'Shopping'; + if (mccCode >= '5800' && mccCode <= '5899') return 'Food & Dining'; + if (mccCode >= '3000' && mccCode <= '3999') return 'Travel'; + if (mccCode >= '4000' && mccCode <= '4799') return 'Transportation'; + if (mccCode >= '7200' && mccCode <= '7999') return 'Entertainment'; + if (mccCode >= '5900' && mccCode <= '5999') return 'Services'; + if (mccCode >= '4800' && mccCode <= '4899') return 'Utilities'; + if (mccCode >= '8000' && mccCode <= '8999') return 'Health & Wellness'; + + return 'Other'; + } +} + diff --git a/src/card/dtos/responses/spending-history.response.dto.ts b/src/card/dtos/responses/spending-history.response.dto.ts new file mode 100644 index 0000000..16e6460 --- /dev/null +++ b/src/card/dtos/responses/spending-history.response.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { SpendingHistoryItemDto } from './spending-history-item.response.dto'; + +export class SpendingHistoryResponseDto { + @ApiProperty({ type: [SpendingHistoryItemDto] }) + transactions!: SpendingHistoryItemDto[]; + + @ApiProperty({ example: 150.75 }) + totalSpent!: number; + + @ApiProperty({ example: 'SAR' }) + currency!: string; + + @ApiProperty({ example: 10 }) + count!: number; + + constructor(transactions: SpendingHistoryItemDto[], currency: string = 'SAR') { + this.transactions = transactions; + this.totalSpent = transactions.reduce((sum, tx) => sum + tx.amount, 0); + this.currency = currency; + this.count = transactions.length; + } +} + diff --git a/src/card/dtos/responses/transaction-detail.response.dto.ts b/src/card/dtos/responses/transaction-detail.response.dto.ts new file mode 100644 index 0000000..5ad671e --- /dev/null +++ b/src/card/dtos/responses/transaction-detail.response.dto.ts @@ -0,0 +1,74 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transaction } from '~/card/entities/transaction.entity'; + +export class TransactionDetailResponseDto { + @ApiProperty() + id!: string; + + @ApiProperty({ example: '2025-10-14T09:53:40.000Z' }) + date!: Date; + + @ApiProperty({ example: 50.5 }) + amount!: number; + + @ApiProperty({ example: 'SAR' }) + currency!: string; + + @ApiProperty({ example: 2.5 }) + fees!: number; + + @ApiProperty({ example: 0.5 }) + vatOnFees!: number; + + @ApiPropertyOptional({ example: 'Target Store' }) + merchantName!: string | null; + + @ApiPropertyOptional({ example: 'Shopping' }) + category!: string | null; + + @ApiPropertyOptional({ example: 'Riyadh' }) + merchantCity!: string | null; + + @ApiProperty({ example: '277012*****3456' }) + cardMasked!: string; + + @ApiProperty() + rrn!: string; + + @ApiProperty() + transactionId!: string; + + constructor(transaction: Transaction) { + this.id = transaction.id; + this.date = transaction.transactionDate; + this.amount = transaction.transactionAmount; + this.currency = transaction.transactionCurrency === '682' ? 'SAR' : transaction.transactionCurrency; + this.fees = transaction.fees; + this.vatOnFees = transaction.vatOnFees; + this.merchantName = transaction.merchantName; + this.category = this.mapMccToCategory(transaction.merchantCategoryCode); + this.merchantCity = transaction.merchantCity; + this.cardMasked = transaction.cardMaskedNumber; + this.rrn = transaction.rrn; + this.transactionId = transaction.transactionId; + } + + private mapMccToCategory(mcc: string | null): string { + if (!mcc) return 'Other'; + + const mccCode = mcc; + + // Map MCC codes to categories + if (mccCode >= '5200' && mccCode <= '5599') return 'Shopping'; + if (mccCode >= '5800' && mccCode <= '5899') return 'Food & Dining'; + if (mccCode >= '3000' && mccCode <= '3999') return 'Travel'; + if (mccCode >= '4000' && mccCode <= '4799') return 'Transportation'; + if (mccCode >= '7200' && mccCode <= '7999') return 'Entertainment'; + if (mccCode >= '5900' && mccCode <= '5999') return 'Services'; + if (mccCode >= '4800' && mccCode <= '4899') return 'Utilities'; + if (mccCode >= '8000' && mccCode <= '8999') return 'Health & Wellness'; + + return 'Other'; + } +} + diff --git a/src/card/entities/transaction.entity.ts b/src/card/entities/transaction.entity.ts index 9db3c0d..85de477 100644 --- a/src/card/entities/transaction.entity.ts +++ b/src/card/entities/transaction.entity.ts @@ -59,6 +59,15 @@ export class Transaction { @Column({ type: 'decimal', name: 'vat_on_fees', precision: 12, scale: 2, default: 0.0 }) vatOnFees!: number; + @Column({ name: 'merchant_name', type: 'varchar', nullable: true }) + merchantName!: string | null; + + @Column({ name: 'merchant_category_code', type: 'varchar', nullable: true }) + merchantCategoryCode!: string | null; + + @Column({ name: 'merchant_city', type: 'varchar', nullable: true }) + merchantCity!: string | null; + @Column({ name: 'card_id', type: 'uuid', nullable: true }) cardId!: string; diff --git a/src/card/repositories/transaction.repository.ts b/src/card/repositories/transaction.repository.ts index 118598d..13ccee1 100644 --- a/src/card/repositories/transaction.repository.ts +++ b/src/card/repositories/transaction.repository.ts @@ -34,6 +34,9 @@ export class TransactionRepository { accountReference: card.account!.accountReference, transactionScope: TransactionScope.CARD, vatOnFees: transactionData.vatOnFees, + merchantName: transactionData.cardAcceptorLocation?.merchantName || null, + merchantCategoryCode: transactionData.cardAcceptorLocation?.mcc || null, + merchantCity: transactionData.cardAcceptorLocation?.merchantCity || null, }), ); } @@ -168,4 +171,13 @@ export class TransactionRepository { .andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL }) .getCount(); } + + findTransactionById(transactionId: string, juniorId: string): Promise { + return this.transactionRepository + .createQueryBuilder('tx') + .innerJoinAndSelect('tx.card', 'card') + .where('tx.id = :transactionId', { transactionId }) + .andWhere('card.customerId = :juniorId', { juniorId }) + .getOne(); + } } diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index a215c09..c45501a 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -251,6 +251,29 @@ export class TransactionService { }; } + async getChildSpendingHistory(juniorId: string, startUtc: Date, endUtc: Date) { + const transactions = await this.transactionRepository.getTransactionsForCardWithinDateRange( + juniorId, + startUtc, + endUtc, + ); + + const { SpendingHistoryItemDto, SpendingHistoryResponseDto } = await import('../dtos/responses'); + const items = transactions.map((t) => new SpendingHistoryItemDto(t)); + return new SpendingHistoryResponseDto(items); + } + + async getTransactionDetail(transactionId: string, juniorId: string) { + const transaction = await this.transactionRepository.findTransactionById(transactionId, juniorId); + + if (!transaction) { + throw new UnprocessableEntityException('TRANSACTION.NOT_FOUND'); + } + + const { TransactionDetailResponseDto } = await import('../dtos/responses'); + return new TransactionDetailResponseDto(transaction); + } + private mapParentItem(t: Transaction): TransactionItemResponseDto { const dto = new TransactionItemResponseDto(); dto.date = t.transactionDate; diff --git a/src/db/migrations/1760869651296-AddMerchantInfoToTransactions.ts b/src/db/migrations/1760869651296-AddMerchantInfoToTransactions.ts new file mode 100644 index 0000000..686e11a --- /dev/null +++ b/src/db/migrations/1760869651296-AddMerchantInfoToTransactions.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddMerchantInfoToTransactions1760869651296 implements MigrationInterface { + name = 'AddMerchantInfoToTransactions1760869651296' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "transactions" ADD COLUMN IF NOT EXISTS "merchant_name" character varying`); + await queryRunner.query(`ALTER TABLE "transactions" ADD COLUMN IF NOT EXISTS "merchant_category_code" character varying`); + await queryRunner.query(`ALTER TABLE "transactions" ADD COLUMN IF NOT EXISTS "merchant_city" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "merchant_city"`); + await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "merchant_category_code"`); + await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "merchant_name"`); + } + +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index ca2c3b5..d5bdf52 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -4,3 +4,4 @@ export * from './1754915164810-seed-default-avatar'; export * from './1757349525708-create-money-requests-table'; export * from './1757433339849-add-reservation-amount-to-account-entity'; export * from './1757915357218-add-deleted-at-column-to-junior'; +export * from './1760869651296-AddMerchantInfoToTransactions'; \ No newline at end of file diff --git a/src/junior/controllers/junior.controller.ts b/src/junior/controllers/junior.controller.ts index f02c769..0ffd532 100644 --- a/src/junior/controllers/junior.controller.ts +++ b/src/junior/controllers/junior.controller.ts @@ -191,4 +191,31 @@ export class JuniorController { const res = await this.juniorService.getJuniorTransfers(juniorId, user.sub, pageNum, pageSize); return ResponseFactory.data(res); } + + @Get(':juniorId/spending-history') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN) + @ApiQuery({ name: 'startUtc', required: true, type: String, example: '2025-01-01T00:00:00.000Z' }) + @ApiQuery({ name: 'endUtc', required: true, type: String, example: '2025-01-31T23:59:59.999Z' }) + async getSpendingHistory( + @Param('juniorId', CustomParseUUIDPipe) juniorId: string, + @AuthenticatedUser() user: IJwtPayload, + @Query('startUtc') startUtc: string, + @Query('endUtc') endUtc: string, + ) { + const res = await this.juniorService.getSpendingHistory(juniorId, user.sub, new Date(startUtc), new Date(endUtc)); + return ResponseFactory.data(res); + } + + @Get(':juniorId/transactions/:transactionId') + @UseGuards(RolesGuard) + @AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN) + async getTransactionDetail( + @Param('juniorId', CustomParseUUIDPipe) juniorId: string, + @Param('transactionId', CustomParseUUIDPipe) transactionId: string, + @AuthenticatedUser() user: IJwtPayload, + ) { + const res = await this.juniorService.getTransactionDetail(juniorId, user.sub, transactionId); + return ResponseFactory.data(res); + } } diff --git a/src/junior/services/junior.service.ts b/src/junior/services/junior.service.ts index 203ce1f..21c4ab9 100644 --- a/src/junior/services/junior.service.ts +++ b/src/junior/services/junior.service.ts @@ -273,6 +273,42 @@ export class JuniorService { return this.transactionService.getChildTransfersPaginated(juniorId, page, size); } + async getSpendingHistory(juniorId: string, userId: string, startUtc: Date, endUtc: Date) { + this.logger.log(`Getting spending history for junior ${juniorId}`); + + // Check if user is the junior themselves or their guardian + let junior: Junior | null; + if (juniorId === userId) { + junior = await this.findJuniorById(juniorId, false); + } else { + junior = await this.findJuniorById(juniorId, false, userId); + } + + if (!junior) { + throw new BadRequestException('JUNIOR.NOT_FOUND'); + } + + return this.transactionService.getChildSpendingHistory(juniorId, startUtc, endUtc); + } + + async getTransactionDetail(juniorId: string, userId: string, transactionId: string) { + this.logger.log(`Getting transaction detail ${transactionId} for junior ${juniorId}`); + + // Check if user is the junior themselves or their guardian + let junior: Junior | null; + if (juniorId === userId) { + junior = await this.findJuniorById(juniorId, false); + } else { + junior = await this.findJuniorById(juniorId, false, userId); + } + + if (!junior) { + throw new BadRequestException('JUNIOR.NOT_FOUND'); + } + + return this.transactionService.getTransactionDetail(transactionId, juniorId); + } + private async prepareJuniorImages(juniors: Junior[]) { this.logger.log(`Preparing junior images`); await Promise.all(