mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-11-26 08:34:55 +00:00
Compare commits
29 Commits
feat/paren
...
5ffe18ede3
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ffe18ede3 | |||
| a3a61b4923 | |||
| 39d5fc1869 | |||
| 05a6ad2d84 | |||
| 5649d24724 | |||
| bbeece9e03 | |||
| 596562f6dc | |||
| 10de8f69c9 | |||
| 8a6b1cc900 | |||
| d16ae66252 | |||
| e966f95463 | |||
| 2714255dd1 | |||
| 39a0b131b8 | |||
| 4f778f7904 | |||
| 7e9bc397a9 | |||
| 7bfc14f0d9 | |||
| d2e084d3e4 | |||
| f81714a525 | |||
| f3282a680b | |||
| 7b57277a7f | |||
| fdd2e23669 | |||
| d70ab09960 | |||
| 297a2fe5ad | |||
| 33b4f13ec8 | |||
| 310233c519 | |||
| 15621124ad | |||
| 7fc1918de0 | |||
| dd6886ff2b | |||
| 649191f3f4 |
16
src/card/dtos/responses/child-transfer-item.response.dto.ts
Normal file
16
src/card/dtos/responses/child-transfer-item.response.dto.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ChildTransferItemDto {
|
||||||
|
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
|
||||||
|
date!: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 50.0 })
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SAR' })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'You received {{amount}} {{currency}} from your parent.' })
|
||||||
|
message!: string;
|
||||||
|
}
|
||||||
|
|
||||||
@ -4,3 +4,12 @@ export * from './child-card.response.dto';
|
|||||||
export * from './transaction-item.response.dto';
|
export * from './transaction-item.response.dto';
|
||||||
export * from './guardian-home.response.dto';
|
export * from './guardian-home.response.dto';
|
||||||
export * from './paged-transactions.response.dto';
|
export * from './paged-transactions.response.dto';
|
||||||
|
export * from './parent-transfer-item.response.dto';
|
||||||
|
export * from './parent-home.response.dto';
|
||||||
|
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';
|
||||||
|
|||||||
16
src/card/dtos/responses/junior-home.response.dto.ts
Normal file
16
src/card/dtos/responses/junior-home.response.dto.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ChildTransferItemDto } from './child-transfer-item.response.dto';
|
||||||
|
|
||||||
|
export class JuniorHomeResponseDto {
|
||||||
|
@ApiProperty({ example: 500.0 })
|
||||||
|
availableBalance!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [ChildTransferItemDto] })
|
||||||
|
recentTransfers!: ChildTransferItemDto[];
|
||||||
|
|
||||||
|
constructor(availableBalance: number, recentTransfers: ChildTransferItemDto[]) {
|
||||||
|
this.availableBalance = availableBalance;
|
||||||
|
this.recentTransfers = recentTransfers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ChildTransferItemDto } from './child-transfer-item.response.dto';
|
||||||
|
|
||||||
|
export class PagedChildTransfersResponseDto {
|
||||||
|
@ApiProperty({ type: [ChildTransferItemDto] })
|
||||||
|
items!: ChildTransferItemDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1 })
|
||||||
|
page!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10 })
|
||||||
|
size!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 20 })
|
||||||
|
total!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
hasMore!: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
items: ChildTransferItemDto[],
|
||||||
|
page: number,
|
||||||
|
size: number,
|
||||||
|
total: number,
|
||||||
|
) {
|
||||||
|
this.items = items;
|
||||||
|
this.page = page;
|
||||||
|
this.size = size;
|
||||||
|
this.total = total;
|
||||||
|
this.hasMore = page * size < total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ParentTransferItemDto } from './parent-transfer-item.response.dto';
|
||||||
|
|
||||||
|
export class PagedParentTransfersResponseDto {
|
||||||
|
@ApiProperty({ type: [ParentTransferItemDto] })
|
||||||
|
items!: ParentTransferItemDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1 })
|
||||||
|
page!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10 })
|
||||||
|
size!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 45 })
|
||||||
|
total!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
hasMore!: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
items: ParentTransferItemDto[],
|
||||||
|
page: number,
|
||||||
|
size: number,
|
||||||
|
total: number,
|
||||||
|
) {
|
||||||
|
this.items = items;
|
||||||
|
this.page = page;
|
||||||
|
this.size = size;
|
||||||
|
this.total = total;
|
||||||
|
this.hasMore = page * size < total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
16
src/card/dtos/responses/parent-home.response.dto.ts
Normal file
16
src/card/dtos/responses/parent-home.response.dto.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ParentTransferItemDto } from './parent-transfer-item.response.dto';
|
||||||
|
|
||||||
|
export class ParentHomeResponseDto {
|
||||||
|
@ApiProperty({ example: 2000.0 })
|
||||||
|
availableBalance!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [ParentTransferItemDto] })
|
||||||
|
recentTransfers!: ParentTransferItemDto[];
|
||||||
|
|
||||||
|
constructor(availableBalance: number, recentTransfers: ParentTransferItemDto[]) {
|
||||||
|
this.availableBalance = availableBalance;
|
||||||
|
this.recentTransfers = recentTransfers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
16
src/card/dtos/responses/parent-transfer-item.response.dto.ts
Normal file
16
src/card/dtos/responses/parent-transfer-item.response.dto.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ParentTransferItemDto {
|
||||||
|
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
|
||||||
|
date!: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 50.0 })
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SAR' })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Ahmed Ali' })
|
||||||
|
childName!: string;
|
||||||
|
}
|
||||||
|
|
||||||
@ -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 })
|
@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;
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -144,4 +147,37 @@ export class TransactionRepository {
|
|||||||
.andWhere('parentCards.customerId = :guardianCustomerId', { guardianCustomerId })
|
.andWhere('parentCards.customerId = :guardianCustomerId', { guardianCustomerId })
|
||||||
.getCount();
|
.getCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findTransfersToJunior(juniorId: string, skip: number, take: number): Promise<Transaction[]> {
|
||||||
|
return this.transactionRepository
|
||||||
|
.createQueryBuilder('tx')
|
||||||
|
.innerJoinAndSelect('tx.card', 'card')
|
||||||
|
.innerJoinAndSelect('card.account', 'account')
|
||||||
|
.where('card.customerId = :juniorId', { juniorId })
|
||||||
|
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
|
||||||
|
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
|
||||||
|
.orderBy('tx.transactionDate', 'DESC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(take)
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
countTransfersToJunior(juniorId: string): Promise<number> {
|
||||||
|
return this.transactionRepository
|
||||||
|
.createQueryBuilder('tx')
|
||||||
|
.innerJoin('tx.card', 'card')
|
||||||
|
.where('card.customerId = :juniorId', { juniorId })
|
||||||
|
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
|
||||||
|
.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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -163,8 +163,8 @@ export class CardService {
|
|||||||
return finalAmount.toNumber();
|
return finalAmount.toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
getWeeklySummary(juniorId: string) {
|
getWeeklySummary(juniorId: string, startDate?: Date, endDate?: Date) {
|
||||||
return this.transactionService.getWeeklySummary(juniorId);
|
return this.transactionService.getWeeklySummary(juniorId, startDate, endDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
fundIban(iban: string, amount: number) {
|
fundIban(iban: string, amount: number) {
|
||||||
|
|||||||
@ -11,7 +11,14 @@ import { CustomerType, TransactionType } from '../enums';
|
|||||||
import { TransactionRepository } from '../repositories/transaction.repository';
|
import { TransactionRepository } from '../repositories/transaction.repository';
|
||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
import { CardService } from './card.service';
|
import { CardService } from './card.service';
|
||||||
import { TransactionItemResponseDto, PagedTransactionsResponseDto } from '../dtos/responses';
|
import {
|
||||||
|
TransactionItemResponseDto,
|
||||||
|
PagedTransactionsResponseDto,
|
||||||
|
ParentTransferItemDto,
|
||||||
|
PagedParentTransfersResponseDto,
|
||||||
|
ChildTransferItemDto,
|
||||||
|
PagedChildTransfersResponseDto,
|
||||||
|
} from '../dtos/responses';
|
||||||
import { ParentTransactionType } from '../enums';
|
import { ParentTransactionType } from '../enums';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -77,15 +84,28 @@ export class TransactionService {
|
|||||||
return existingTransaction;
|
return existingTransaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWeeklySummary(juniorId: string) {
|
async getWeeklySummary(juniorId: string, startDate?: Date, endDate?: Date) {
|
||||||
const startOfWeek = moment().startOf('week').toDate();
|
let startOfWeek: Date;
|
||||||
const endOfWeek = moment().endOf('week').toDate();
|
let endOfWeek: Date;
|
||||||
|
|
||||||
|
if (startDate && endDate) {
|
||||||
|
startOfWeek = startDate;
|
||||||
|
endOfWeek = endDate;
|
||||||
|
} else {
|
||||||
|
const now = moment();
|
||||||
|
const dayOfWeek = now.day();
|
||||||
|
|
||||||
|
startOfWeek = moment().subtract(dayOfWeek, 'days').startOf('day').toDate();
|
||||||
|
|
||||||
|
endOfWeek = moment().add(6 - dayOfWeek, 'days').endOf('day').toDate();
|
||||||
|
}
|
||||||
|
|
||||||
const transactions = await this.transactionRepository.getTransactionsForCardWithinDateRange(
|
const transactions = await this.transactionRepository.getTransactionsForCardWithinDateRange(
|
||||||
juniorId,
|
juniorId,
|
||||||
startOfWeek,
|
startOfWeek,
|
||||||
endOfWeek,
|
endOfWeek,
|
||||||
);
|
);
|
||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
startOfWeek: startOfWeek,
|
startOfWeek: startOfWeek,
|
||||||
endOfWeek: endOfWeek,
|
endOfWeek: endOfWeek,
|
||||||
@ -182,6 +202,91 @@ export class TransactionService {
|
|||||||
return new PagedTransactionsResponseDto(mapped, page, size, total);
|
return new PagedTransactionsResponseDto(mapped, page, size, total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getParentTransfersOnly(guardianCustomerId: string, page: number, size: number): Promise<ParentTransferItemDto[]> {
|
||||||
|
const skip = (page - 1) * size;
|
||||||
|
const transfers = await this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size);
|
||||||
|
return transfers.map((t) => this.mapToParentTransferItem(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getParentTransfersPaginated(
|
||||||
|
guardianCustomerId: string,
|
||||||
|
page: number,
|
||||||
|
size: number,
|
||||||
|
): Promise<PagedParentTransfersResponseDto> {
|
||||||
|
const skip = (page - 1) * size;
|
||||||
|
const [transfers, total] = await Promise.all([
|
||||||
|
this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size),
|
||||||
|
this.transactionRepository.countParentTransfers(guardianCustomerId),
|
||||||
|
]);
|
||||||
|
const items = transfers.map((t) => this.mapToParentTransferItem(t));
|
||||||
|
return new PagedParentTransfersResponseDto(items, page, size, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChildTransfers(juniorId: string, page: number, size: number): Promise<ChildTransferItemDto[]> {
|
||||||
|
const skip = (page - 1) * size;
|
||||||
|
const transfers = await this.transactionRepository.findTransfersToJunior(juniorId, skip, size);
|
||||||
|
return transfers.map((t) => this.mapToChildTransferItem(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChildTransfersPaginated(
|
||||||
|
juniorId: string,
|
||||||
|
page: number,
|
||||||
|
size: number,
|
||||||
|
): Promise<PagedChildTransfersResponseDto> {
|
||||||
|
const skip = (page - 1) * size;
|
||||||
|
const [transfers, total] = await Promise.all([
|
||||||
|
this.transactionRepository.findTransfersToJunior(juniorId, skip, size),
|
||||||
|
this.transactionRepository.countTransfersToJunior(juniorId),
|
||||||
|
]);
|
||||||
|
const items = transfers.map((t) => this.mapToChildTransferItem(t));
|
||||||
|
return new PagedChildTransfersResponseDto(items, page, size, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToParentTransferItem(t: Transaction): ParentTransferItemDto {
|
||||||
|
const child = t.card?.customer;
|
||||||
|
const currency = t.transactionCurrency === '682' ? 'SAR' : t.transactionCurrency;
|
||||||
|
return {
|
||||||
|
date: t.transactionDate,
|
||||||
|
amount: Math.abs(t.transactionAmount),
|
||||||
|
currency,
|
||||||
|
childName: child ? `${child.firstName} ${child.lastName}` : 'Child',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToChildTransferItem(t: Transaction): ChildTransferItemDto {
|
||||||
|
const amount = Math.abs(t.transactionAmount);
|
||||||
|
const currency = t.transactionCurrency === '682' ? 'SAR' : t.transactionCurrency;
|
||||||
|
return {
|
||||||
|
date: t.transactionDate,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
message: `You received {{amount}} {{currency}} from your parent.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
@ -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 './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';
|
||||||
|
export * from './1761032305682-AddUniqueConstraintToUserEmail';
|
||||||
@ -6,8 +6,7 @@ import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
|
|||||||
import { AccessTokenGuard, RolesGuard } from '~/common/guards';
|
import { AccessTokenGuard, RolesGuard } from '~/common/guards';
|
||||||
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
|
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
|
||||||
import { ResponseFactory } from '~/core/utils';
|
import { ResponseFactory } from '~/core/utils';
|
||||||
import { GuardianHomeResponseDto, PagedTransactionsResponseDto } from '~/card/dtos/responses';
|
import { ParentHomeResponseDto, PagedParentTransfersResponseDto } from '~/card/dtos/responses';
|
||||||
import { ParentTransactionType } from '~/card/enums';
|
|
||||||
import { GuardianTransactionsService } from '../services';
|
import { GuardianTransactionsService } from '../services';
|
||||||
|
|
||||||
|
|
||||||
@ -22,7 +21,7 @@ export class GuardianTransactionsController {
|
|||||||
|
|
||||||
@Get('home')
|
@Get('home')
|
||||||
@ApiQuery({ name: 'size', required: false, type: Number, example: 5 })
|
@ApiQuery({ name: 'size', required: false, type: Number, example: 5 })
|
||||||
@ApiDataResponse(GuardianHomeResponseDto)
|
@ApiDataResponse(ParentHomeResponseDto)
|
||||||
async getHome(
|
async getHome(
|
||||||
@AuthenticatedUser() user: IJwtPayload,
|
@AuthenticatedUser() user: IJwtPayload,
|
||||||
@Query('size') size?: number,
|
@Query('size') size?: number,
|
||||||
@ -32,20 +31,18 @@ export class GuardianTransactionsController {
|
|||||||
return ResponseFactory.data(res);
|
return ResponseFactory.data(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('transactions')
|
@Get('transfers')
|
||||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||||
@ApiQuery({ name: 'size', required: false, type: Number, example: 10 })
|
@ApiQuery({ name: 'size', required: false, type: Number, example: 10 })
|
||||||
@ApiQuery({ name: 'type', required: false, enum: ParentTransactionType })
|
@ApiDataResponse(PagedParentTransfersResponseDto)
|
||||||
@ApiDataResponse(PagedTransactionsResponseDto)
|
async getTransfers(
|
||||||
async getTransactions(
|
|
||||||
@AuthenticatedUser() user: IJwtPayload,
|
@AuthenticatedUser() user: IJwtPayload,
|
||||||
@Query('page') page?: number,
|
@Query('page') page?: number,
|
||||||
@Query('size') size?: number,
|
@Query('size') size?: number,
|
||||||
@Query('type') type?: ParentTransactionType,
|
|
||||||
) {
|
) {
|
||||||
const pageNum = Math.max(1, Number(page) || 1);
|
const pageNum = Math.max(1, Number(page) || 1);
|
||||||
const pageSize = Math.max(1, Math.min(Number(size) || 10, 50));
|
const pageSize = Math.max(1, Math.min(Number(size) || 10, 50));
|
||||||
const res = await this.guardianTxService.getTransactions(user.sub, pageNum, pageSize, type);
|
const res = await this.guardianTxService.getTransfers(user.sub, pageNum, pageSize);
|
||||||
return ResponseFactory.data(res);
|
return ResponseFactory.data(res);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CustomerService } from '~/customer/services';
|
import { CustomerService } from '~/customer/services';
|
||||||
import { GuardianHomeResponseDto, PagedTransactionsResponseDto } from '~/card/dtos/responses';
|
import { ParentHomeResponseDto, PagedParentTransfersResponseDto } from '~/card/dtos/responses';
|
||||||
import { TransactionItemResponseDto } from '~/card/dtos/responses';
|
|
||||||
import { ParentTransactionType } from '~/card/enums';
|
|
||||||
import { TransactionService } from '~/card/services/transaction.service';
|
import { TransactionService } from '~/card/services/transaction.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -12,7 +10,7 @@ export class GuardianTransactionsService {
|
|||||||
private readonly transactionService: TransactionService,
|
private readonly transactionService: TransactionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getHome(guardianId: string, size: number): Promise<GuardianHomeResponseDto> {
|
async getHome(guardianId: string, size: number): Promise<ParentHomeResponseDto> {
|
||||||
const parent = await this.customerService.findCustomerById(guardianId);
|
const parent = await this.customerService.findCustomerById(guardianId);
|
||||||
const primaryCard = parent.cards?.[0];
|
const primaryCard = parent.cards?.[0];
|
||||||
|
|
||||||
@ -27,18 +25,17 @@ export class GuardianTransactionsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: TransactionItemResponseDto[] = await this.transactionService.getParentConsolidated(guardianId, 1, size);
|
const recentTransfers = await this.transactionService.getParentTransfersOnly(guardianId, 1, size);
|
||||||
|
|
||||||
return new GuardianHomeResponseDto(availableBalance, items);
|
return new ParentHomeResponseDto(availableBalance, recentTransfers);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTransactions(
|
async getTransfers(
|
||||||
guardianId: string,
|
guardianId: string,
|
||||||
page: number,
|
page: number,
|
||||||
size: number,
|
size: number,
|
||||||
type?: ParentTransactionType,
|
): Promise<PagedParentTransfersResponseDto> {
|
||||||
): Promise<PagedTransactionsResponseDto> {
|
return this.transactionService.getParentTransfersPaginated(guardianId, page, size);
|
||||||
return this.transactionService.getParentTransactionsPaginated(guardianId, page, size, type);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags, ApiQuery } from '@nestjs/swagger';
|
||||||
import { Roles } from '~/auth/enums';
|
import { Roles } from '~/auth/enums';
|
||||||
import { IJwtPayload } from '~/auth/interfaces';
|
import { IJwtPayload } from '~/auth/interfaces';
|
||||||
import { AllowedRoles, AuthenticatedUser, Public } from '~/common/decorators';
|
import { AllowedRoles, AuthenticatedUser, Public } from '~/common/decorators';
|
||||||
@ -33,6 +33,7 @@ import {
|
|||||||
TransferToJuniorResponseDto,
|
TransferToJuniorResponseDto,
|
||||||
} from '../dtos/response';
|
} from '../dtos/response';
|
||||||
import { WeeklySummaryResponseDto } from '../dtos/response/weekly-summary.response.dto';
|
import { WeeklySummaryResponseDto } from '../dtos/response/weekly-summary.response.dto';
|
||||||
|
import { JuniorHomeResponseDto, PagedChildTransfersResponseDto } from '~/card/dtos/responses';
|
||||||
import { JuniorService } from '../services';
|
import { JuniorService } from '../services';
|
||||||
|
|
||||||
@Controller('juniors')
|
@Controller('juniors')
|
||||||
@ -150,11 +151,77 @@ export class JuniorController {
|
|||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@AllowedRoles(Roles.GUARDIAN)
|
@AllowedRoles(Roles.GUARDIAN)
|
||||||
@ApiDataResponse(WeeklySummaryResponseDto)
|
@ApiDataResponse(WeeklySummaryResponseDto)
|
||||||
|
@ApiQuery({ name: 'startUtc', required: false, type: String, example: '2025-10-20T00:00:00.000Z', description: 'Start date (defaults to start of current week)' })
|
||||||
|
@ApiQuery({ name: 'endUtc', required: false, type: String, example: '2025-10-26T23:59:59.999Z', description: 'End date (defaults to end of current week)' })
|
||||||
async getWeeklySummary(
|
async getWeeklySummary(
|
||||||
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
|
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
|
||||||
@AuthenticatedUser() user: IJwtPayload,
|
@AuthenticatedUser() user: IJwtPayload,
|
||||||
|
@Query('startUtc') startUtc?: string,
|
||||||
|
@Query('endUtc') endUtc?: string,
|
||||||
) {
|
) {
|
||||||
const summary = await this.juniorService.getWeeklySummary(juniorId, user.sub);
|
const startDate = startUtc ? new Date(startUtc) : undefined;
|
||||||
|
const endDate = endUtc ? new Date(endUtc) : undefined;
|
||||||
|
const summary = await this.juniorService.getWeeklySummary(juniorId, user.sub, startDate, endDate);
|
||||||
return ResponseFactory.data(summary);
|
return ResponseFactory.data(summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':juniorId/home')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN)
|
||||||
|
@ApiQuery({ name: 'size', required: false, type: Number, example: 5 })
|
||||||
|
@ApiDataResponse(JuniorHomeResponseDto)
|
||||||
|
async getJuniorHome(
|
||||||
|
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
|
||||||
|
@AuthenticatedUser() user: IJwtPayload,
|
||||||
|
@Query('size') size?: number,
|
||||||
|
) {
|
||||||
|
const limit = Math.max(1, Math.min(Number(size) || 5, 20));
|
||||||
|
const res = await this.juniorService.getJuniorHome(juniorId, user.sub, limit);
|
||||||
|
return ResponseFactory.data(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':juniorId/transfers')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
@AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN)
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||||
|
@ApiQuery({ name: 'size', required: false, type: Number, example: 10 })
|
||||||
|
@ApiDataResponse(PagedChildTransfersResponseDto)
|
||||||
|
async getJuniorTransfers(
|
||||||
|
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
|
||||||
|
@AuthenticatedUser() user: IJwtPayload,
|
||||||
|
@Query('page') page?: number,
|
||||||
|
@Query('size') size?: number,
|
||||||
|
) {
|
||||||
|
const pageNum = Math.max(1, Number(page) || 1);
|
||||||
|
const pageSize = Math.max(1, Math.min(Number(size) || 10, 50));
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Gender } from '~/customer/enums';
|
||||||
import { Guardian } from '~/guardian/entities/guradian.entity';
|
import { Guardian } from '~/guardian/entities/guradian.entity';
|
||||||
import { Junior } from '~/junior/entities';
|
import { Junior } from '~/junior/entities';
|
||||||
import { GuardianRelationship } from '~/junior/enums';
|
import { ChildRelationshipLabel, GuardianRelationship, Relationship } from '~/junior/enums';
|
||||||
|
|
||||||
export class QrCodeValidationDetailsResponse {
|
export class QrCodeValidationDetailsResponse {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@ -26,6 +27,17 @@ export class QrCodeValidationDetailsResponse {
|
|||||||
this.phoneNumber = person.customer.user.phoneNumber;
|
this.phoneNumber = person.customer.user.phoneNumber;
|
||||||
this.email = person.customer.user.email;
|
this.email = person.customer.user.email;
|
||||||
this.dateOfBirth = person.customer.dateOfBirth;
|
this.dateOfBirth = person.customer.dateOfBirth;
|
||||||
this.relationship = guardian ? junior.relationship : GuardianRelationship[junior.relationship];
|
|
||||||
|
if (guardian) {
|
||||||
|
this.relationship = junior.relationship;
|
||||||
|
} else {
|
||||||
|
if (junior.relationship === Relationship.PARENT) {
|
||||||
|
this.relationship = junior.customer.gender === Gender.MALE
|
||||||
|
? ChildRelationshipLabel.SON
|
||||||
|
: ChildRelationshipLabel.DAUGHTER;
|
||||||
|
} else {
|
||||||
|
this.relationship = GuardianRelationship[junior.relationship];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/junior/enums/child-relationship-label.enum.ts
Normal file
5
src/junior/enums/child-relationship-label.enum.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export enum ChildRelationshipLabel {
|
||||||
|
SON = 'SON',
|
||||||
|
DAUGHTER = 'DAUGHTER',
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
export * from './child-relationship-label.enum';
|
||||||
export * from './guardian-relationship.enum';
|
export * from './guardian-relationship.enum';
|
||||||
export * from './relationship.enum';
|
export * from './relationship.enum';
|
||||||
export * from './theme-color.enum';
|
export * from './theme-color.enum';
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
|||||||
import { IsNull, Not } from 'typeorm';
|
import { IsNull, Not } from 'typeorm';
|
||||||
import { Transactional } from 'typeorm-transactional';
|
import { Transactional } from 'typeorm-transactional';
|
||||||
import { Roles } from '~/auth/enums';
|
import { Roles } from '~/auth/enums';
|
||||||
import { CardService } from '~/card/services';
|
import { CardService, TransactionService } from '~/card/services';
|
||||||
import { NeoLeapService } from '~/common/modules/neoleap/services';
|
import { NeoLeapService } from '~/common/modules/neoleap/services';
|
||||||
import { PageOptionsRequestDto } from '~/core/dtos';
|
import { PageOptionsRequestDto } from '~/core/dtos';
|
||||||
import { setIf } from '~/core/utils';
|
import { setIf } from '~/core/utils';
|
||||||
@ -20,6 +20,7 @@ import {
|
|||||||
import { Junior } from '../entities';
|
import { Junior } from '../entities';
|
||||||
import { JuniorRepository } from '../repositories';
|
import { JuniorRepository } from '../repositories';
|
||||||
import { QrcodeService } from './qrcode.service';
|
import { QrcodeService } from './qrcode.service';
|
||||||
|
import { JuniorHomeResponseDto, PagedChildTransfersResponseDto } from '~/card/dtos/responses';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JuniorService {
|
export class JuniorService {
|
||||||
@ -34,6 +35,7 @@ export class JuniorService {
|
|||||||
private readonly qrCodeService: QrcodeService,
|
private readonly qrCodeService: QrcodeService,
|
||||||
private readonly neoleapService: NeoLeapService,
|
private readonly neoleapService: NeoLeapService,
|
||||||
private readonly cardService: CardService,
|
private readonly cardService: CardService,
|
||||||
|
private readonly transactionService: TransactionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Transactional()
|
@Transactional()
|
||||||
@ -118,6 +120,7 @@ export class JuniorService {
|
|||||||
setIf(customer, 'firstName', body.firstName);
|
setIf(customer, 'firstName', body.firstName);
|
||||||
setIf(customer, 'lastName', body.lastName);
|
setIf(customer, 'lastName', body.lastName);
|
||||||
setIf(customer, 'dateOfBirth', body.dateOfBirth as unknown as Date);
|
setIf(customer, 'dateOfBirth', body.dateOfBirth as unknown as Date);
|
||||||
|
setIf(customer, 'gender', body.gender);
|
||||||
|
|
||||||
setIf(junior, 'relationship', body.relationship);
|
setIf(junior, 'relationship', body.relationship);
|
||||||
await Promise.all([junior.save(), customer.save(), user.save()]);
|
await Promise.all([junior.save(), customer.save(), user.save()]);
|
||||||
@ -209,8 +212,8 @@ export class JuniorService {
|
|||||||
this.logger.log(`Junior ${juniorId} deleted successfully`);
|
this.logger.log(`Junior ${juniorId} deleted successfully`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getWeeklySummary(juniorId: string, guardianId: string) {
|
async getWeeklySummary(juniorId: string, guardianId: string, startDate?: Date, endDate?: Date) {
|
||||||
const doesBelong = this.doesJuniorBelongToGuardian(guardianId, juniorId);
|
const doesBelong = await this.doesJuniorBelongToGuardian(guardianId, juniorId);
|
||||||
|
|
||||||
if (!doesBelong) {
|
if (!doesBelong) {
|
||||||
this.logger.error(`Junior ${juniorId} does not belong to guardian ${guardianId}`);
|
this.logger.error(`Junior ${juniorId} does not belong to guardian ${guardianId}`);
|
||||||
@ -218,7 +221,93 @@ export class JuniorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Getting weekly summary for junior ${juniorId}`);
|
this.logger.log(`Getting weekly summary for junior ${juniorId}`);
|
||||||
return this.cardService.getWeeklySummary(juniorId);
|
return this.cardService.getWeeklySummary(juniorId, startDate, endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJuniorHome(juniorId: string, userId: string, size: number): Promise<JuniorHomeResponseDto> {
|
||||||
|
this.logger.log(`Getting home for junior ${juniorId}`);
|
||||||
|
|
||||||
|
// Check if user is the junior themselves or their guardian
|
||||||
|
let junior: Junior | null;
|
||||||
|
if (juniorId === userId) {
|
||||||
|
// User is the junior accessing their own home
|
||||||
|
junior = await this.findJuniorById(juniorId, false);
|
||||||
|
} else {
|
||||||
|
// User might be the guardian accessing junior's home
|
||||||
|
junior = await this.findJuniorById(juniorId, false, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!junior) {
|
||||||
|
throw new BadRequestException('JUNIOR.NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = junior.customer?.cards?.[0];
|
||||||
|
const availableBalance = card ? Math.min(card.limit, card.account.balance) : 0;
|
||||||
|
|
||||||
|
const recentTransfers = await this.transactionService.getChildTransfers(juniorId, 1, size);
|
||||||
|
|
||||||
|
return new JuniorHomeResponseDto(availableBalance, recentTransfers);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJuniorTransfers(
|
||||||
|
juniorId: string,
|
||||||
|
userId: string,
|
||||||
|
page: number,
|
||||||
|
size: number,
|
||||||
|
): Promise<PagedChildTransfersResponseDto> {
|
||||||
|
this.logger.log(`Getting transfers for junior ${juniorId}`);
|
||||||
|
|
||||||
|
// Check if user is the junior themselves or their guardian
|
||||||
|
let junior: Junior | null;
|
||||||
|
if (juniorId === userId) {
|
||||||
|
// User is the junior accessing their own transfers
|
||||||
|
junior = await this.findJuniorById(juniorId, false);
|
||||||
|
} else {
|
||||||
|
// User might be the guardian accessing junior's transfers
|
||||||
|
junior = await this.findJuniorById(juniorId, false, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!junior) {
|
||||||
|
throw new BadRequestException('JUNIOR.NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
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[]) {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { 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 { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||||
|
import { Gender } from '~/customer/enums';
|
||||||
export class UpdateUserRequestDto {
|
export class UpdateUserRequestDto {
|
||||||
@ApiProperty({ example: 'John' })
|
@ApiProperty({ example: 'John' })
|
||||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'user.firstName' }) })
|
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'user.firstName' }) })
|
||||||
@ -14,8 +15,23 @@ export class UpdateUserRequestDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
lastName!: string;
|
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' })
|
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
|
||||||
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'user.profilePictureId' }) })
|
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'user.profilePictureId' }) })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
profilePictureId!: string;
|
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 })
|
@Column('varchar', { length: 255, name: 'last_name', nullable: false })
|
||||||
lastName!: string;
|
lastName!: string;
|
||||||
|
|
||||||
@Column('varchar', { length: 255, name: 'email', nullable: true })
|
@Column('varchar', { length: 255, name: 'email', nullable: true, unique: true })
|
||||||
email!: string;
|
email!: string;
|
||||||
|
|
||||||
@Column('varchar', { length: 255, name: 'phone_number', nullable: true })
|
@Column('varchar', { length: 255, name: 'phone_number', nullable: true })
|
||||||
|
|||||||
@ -191,20 +191,50 @@ export class UserService {
|
|||||||
async updateUser(userId: string, data: UpdateUserRequestDto) {
|
async updateUser(userId: string, data: UpdateUserRequestDto) {
|
||||||
await this.validateProfilePictureId(data.profilePictureId, userId);
|
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)}`);
|
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) {
|
if (affected === 0) {
|
||||||
this.logger.error(`User with id ${userId} not found`);
|
this.logger.error(`User with id ${userId} not found`);
|
||||||
throw new BadRequestException('USER.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) {
|
async updateUserEmail(userId: string, email: string) {
|
||||||
const userWithEmail = await this.findUser({ email, isEmailVerified: true });
|
const userWithEmail = await this.findUser({ email });
|
||||||
|
|
||||||
if (userWithEmail) {
|
if (userWithEmail) {
|
||||||
if (userWithEmail.id === userId) {
|
if (userWithEmail.id === userId) {
|
||||||
return;
|
this.logger.log(`Generating OTP for current email ${email} for user ${userId}`);
|
||||||
|
await this.userRepository.update(userId, { isEmailVerified: false });
|
||||||
|
|
||||||
|
return this.otpService.generateAndSendOtp({
|
||||||
|
userId,
|
||||||
|
recipient: email,
|
||||||
|
otpType: OtpType.EMAIL,
|
||||||
|
scope: OtpScope.VERIFY_EMAIL,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(`Email ${email} is already taken by another user`);
|
this.logger.error(`Email ${email} is already taken by another user`);
|
||||||
|
|||||||
Reference in New Issue
Block a user