Compare commits

...

12 Commits

Author SHA1 Message Date
10de8f69c9 Merge pull request #47 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
Remove duplicate email cleanup logic and add unique constraint to use…
2025-10-21 14:15:03 +03:00
8a6b1cc900 Remove duplicate email cleanup logic and add unique constraint to user email 2025-10-21 14:10:14 +03:00
d16ae66252 Merge pull request #46 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
ZOD-341-Add unique constraint to user email and clean up duplicates
2025-10-21 10:51:12 +03:00
e966f95463 ZOD-341-Add unique constraint to user email and clean up duplicates 2025-10-21 10:49:43 +03:00
39a0b131b8 Merge pull request #44 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
Zod 341 junior a child can edit their email to an existing email causing multiple child accounts to share the same login
2025-10-20 14:27:40 +03:00
4f778f7904 * ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login 2025-10-20 14:25:53 +03:00
7e9bc397a9 Merge pull request #43 from HamzaSha1/ZOD-204-view-spending-from-child-login
git checkout -b ZOD-204-view-spending-from-child-login
2025-10-20 10:30:27 +03:00
7bfc14f0d9 Merge pull request #42 from HamzaSha1/ZOD-204-view-spending-from-child-login
ZOD-204-view-spending-from-child-login
2025-10-19 15:44:16 +03:00
d2e084d3e4 git checkout -b ZOD-204-view-spending-from-child-login 2025-10-19 15:26:47 +03:00
f81714a525 Merge pull request #41 from HamzaSha1/ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
2025-10-19 11:07:39 +03:00
f3282a680b Merge pull request #40 from HamzaSha1/ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
Zod 339 child profile gender update is not reflected after editing
2025-10-19 11:02:40 +03:00
d70ab09960 Merge pull request #38 from HamzaSha1/ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code
Zod 333 junior incorrect relationship label displayed as child instead of daughter or son in child confirmation details after the scan the qr code
2025-10-19 09:58:57 +03:00
15 changed files with 317 additions and 3 deletions

View File

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

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

View File

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

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 {
const dto = new TransactionItemResponseDto();
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

@ -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"`);
}
}

View File

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

View File

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

View File

@ -274,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(

View File

@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
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 {
@ -15,6 +15,11 @@ 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()

View File

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

View File

@ -191,6 +191,14 @@ 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 { gender, dateOfBirth, ...userData } = data;
@ -214,7 +222,7 @@ export class UserService {
}
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) {