mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-11-26 00:24:54 +00:00
Compare commits
14 Commits
ZOD-333-ju
...
10de8f69c9
| Author | SHA1 | Date | |
|---|---|---|---|
| 10de8f69c9 | |||
| 8a6b1cc900 | |||
| d16ae66252 | |||
| e966f95463 | |||
| 39a0b131b8 | |||
| 4f778f7904 | |||
| 7e9bc397a9 | |||
| 7bfc14f0d9 | |||
| d2e084d3e4 | |||
| f81714a525 | |||
| f3282a680b | |||
| 7b57277a7f | |||
| fdd2e23669 | |||
| d70ab09960 |
@ -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';
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
24
src/card/dtos/responses/spending-history.response.dto.ts
Normal file
24
src/card/dtos/responses/spending-history.response.dto.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
74
src/card/dtos/responses/transaction-detail.response.dto.ts
Normal file
74
src/card/dtos/responses/transaction-detail.response.dto.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<Transaction | null> {
|
||||
return this.transactionRepository
|
||||
.createQueryBuilder('tx')
|
||||
.innerJoinAndSelect('tx.card', 'card')
|
||||
.where('tx.id = :transactionId', { transactionId })
|
||||
.andWhere('card.customerId = :juniorId', { juniorId })
|
||||
.getOne();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"`);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddUniqueConstraintToUserEmail1761032305682 implements MigrationInterface {
|
||||
name = 'AddUniqueConstraintToUserEmail1761032305682'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email")`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3"`);
|
||||
}
|
||||
|
||||
}
|
||||
@ -4,3 +4,5 @@ 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';
|
||||
export * from './1761032305682-AddUniqueConstraintToUserEmail';
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,6 +120,7 @@ export class JuniorService {
|
||||
setIf(customer, 'firstName', body.firstName);
|
||||
setIf(customer, 'lastName', body.lastName);
|
||||
setIf(customer, 'dateOfBirth', body.dateOfBirth as unknown as Date);
|
||||
setIf(customer, 'gender', body.gender);
|
||||
|
||||
setIf(junior, 'relationship', body.relationship);
|
||||
await Promise.all([junior.save(), customer.save(), user.save()]);
|
||||
@ -273,6 +274,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(
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||
import { Gender } from '~/customer/enums';
|
||||
export class UpdateUserRequestDto {
|
||||
@ApiProperty({ example: 'John' })
|
||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'user.firstName' }) })
|
||||
@ -14,8 +15,23 @@ export class UpdateUserRequestDto {
|
||||
@IsOptional()
|
||||
lastName!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'child@example.com' })
|
||||
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'user.email' }) })
|
||||
@IsOptional()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
|
||||
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'user.profilePictureId' }) })
|
||||
@IsOptional()
|
||||
profilePictureId!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: Gender })
|
||||
@IsEnum(Gender, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.gender' }) })
|
||||
@IsOptional()
|
||||
gender!: Gender;
|
||||
|
||||
@ApiPropertyOptional({ example: '2020-01-01' })
|
||||
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) })
|
||||
@IsOptional()
|
||||
dateOfBirth!: Date;
|
||||
}
|
||||
|
||||
@ -28,7 +28,7 @@ export class User extends BaseEntity {
|
||||
@Column('varchar', { length: 255, name: 'last_name', nullable: false })
|
||||
lastName!: string;
|
||||
|
||||
@Column('varchar', { length: 255, name: 'email', nullable: true })
|
||||
@Column('varchar', { length: 255, name: 'email', nullable: true, unique: true })
|
||||
email!: string;
|
||||
|
||||
@Column('varchar', { length: 255, name: 'phone_number', nullable: true })
|
||||
|
||||
@ -191,16 +191,38 @@ export class UserService {
|
||||
async updateUser(userId: string, data: UpdateUserRequestDto) {
|
||||
await this.validateProfilePictureId(data.profilePictureId, userId);
|
||||
|
||||
if (data.email) {
|
||||
const userWithEmail = await this.findUser({ email: data.email });
|
||||
if (userWithEmail && userWithEmail.id !== userId) {
|
||||
this.logger.error(`Email ${data.email} is already taken by another user`);
|
||||
throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN');
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Updating user ${userId} with data ${JSON.stringify(data)}`);
|
||||
const { affected } = await this.userRepository.update(userId, data);
|
||||
|
||||
const { gender, dateOfBirth, ...userData } = data;
|
||||
|
||||
const { affected } = await this.userRepository.update(userId, userData);
|
||||
if (affected === 0) {
|
||||
this.logger.error(`User with id ${userId} not found`);
|
||||
throw new BadRequestException('USER.NOT_FOUND');
|
||||
}
|
||||
|
||||
if (gender !== undefined || dateOfBirth !== undefined) {
|
||||
const customerData: Partial<{ gender: typeof gender; dateOfBirth: Date }> = {};
|
||||
if (gender !== undefined) {
|
||||
customerData.gender = gender;
|
||||
}
|
||||
if (dateOfBirth !== undefined) {
|
||||
customerData.dateOfBirth = dateOfBirth;
|
||||
}
|
||||
await this.customerService.updateCustomer(userId, customerData);
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserEmail(userId: string, email: string) {
|
||||
const userWithEmail = await this.findUser({ email, isEmailVerified: true });
|
||||
const userWithEmail = await this.findUser({ email });
|
||||
|
||||
if (userWithEmail) {
|
||||
if (userWithEmail.id === userId) {
|
||||
|
||||
Reference in New Issue
Block a user