Merge pull request #43 from HamzaSha1/ZOD-204-view-spending-from-child-login

git checkout -b ZOD-204-view-spending-from-child-login
This commit is contained in:
abdalhamid99
2025-10-20 10:30:27 +03:00
committed by GitHub
11 changed files with 285 additions and 0 deletions

View File

@ -10,3 +10,6 @@ export * from './paged-parent-transfers.response.dto';
export * from './child-transfer-item.response.dto'; export * from './child-transfer-item.response.dto';
export * from './junior-home.response.dto'; export * from './junior-home.response.dto';
export * from './paged-child-transfers.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';

View File

@ -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';
}
}

View File

@ -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;
}
}

View File

@ -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';
}
}

View File

@ -59,6 +59,15 @@ export class Transaction {
@Column({ type: 'decimal', name: 'vat_on_fees', precision: 12, scale: 2, default: 0.0 }) @Column({ type: 'decimal', name: 'vat_on_fees', precision: 12, scale: 2, default: 0.0 })
vatOnFees!: number; 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 }) @Column({ name: 'card_id', type: 'uuid', nullable: true })
cardId!: string; cardId!: string;

View File

@ -34,6 +34,9 @@ export class TransactionRepository {
accountReference: card.account!.accountReference, accountReference: card.account!.accountReference,
transactionScope: TransactionScope.CARD, transactionScope: TransactionScope.CARD,
vatOnFees: transactionData.vatOnFees, 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 }) .andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.getCount(); .getCount();
} }
findTransactionById(transactionId: string, juniorId: string): Promise<Transaction | null> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.card', 'card')
.where('tx.id = :transactionId', { transactionId })
.andWhere('card.customerId = :juniorId', { juniorId })
.getOne();
}
} }

View File

@ -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 { private mapParentItem(t: Transaction): TransactionItemResponseDto {
const dto = new TransactionItemResponseDto(); const dto = new TransactionItemResponseDto();
dto.date = t.transactionDate; dto.date = t.transactionDate;

View File

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddMerchantInfoToTransactions1760869651296 implements MigrationInterface {
name = 'AddMerchantInfoToTransactions1760869651296'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

View File

@ -4,3 +4,4 @@ export * from './1754915164810-seed-default-avatar';
export * from './1757349525708-create-money-requests-table'; export * from './1757349525708-create-money-requests-table';
export * from './1757433339849-add-reservation-amount-to-account-entity'; export * from './1757433339849-add-reservation-amount-to-account-entity';
export * from './1757915357218-add-deleted-at-column-to-junior'; export * from './1757915357218-add-deleted-at-column-to-junior';
export * from './1760869651296-AddMerchantInfoToTransactions';

View File

@ -191,4 +191,31 @@ export class JuniorController {
const res = await this.juniorService.getJuniorTransfers(juniorId, user.sub, pageNum, pageSize); const res = await this.juniorService.getJuniorTransfers(juniorId, user.sub, pageNum, pageSize);
return ResponseFactory.data(res); 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);
}
} }

View File

@ -274,6 +274,42 @@ export class JuniorService {
return this.transactionService.getChildTransfersPaginated(juniorId, page, size); 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[]) { private async prepareJuniorImages(juniors: Junior[]) {
this.logger.log(`Preparing junior images`); this.logger.log(`Preparing junior images`);
await Promise.all( await Promise.all(