mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-11-26 00:24:54 +00:00
Compare commits
9 Commits
918b15c315
...
33b4f13ec8
| Author | SHA1 | Date | |
|---|---|---|---|
| 33b4f13ec8 | |||
| 310233c519 | |||
| 15621124ad | |||
| 7fc1918de0 | |||
| f6fa74897a | |||
| dd6886ff2b | |||
| 649191f3f4 | |||
| 183f6b4475 | |||
| 8f601b26ae |
@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Gender } from '~/customer/enums';
|
||||||
import { DocumentMetaResponseDto } from '~/document/dtos/response';
|
import { DocumentMetaResponseDto } from '~/document/dtos/response';
|
||||||
import { User } from '~/user/entities';
|
import { User } from '~/user/entities';
|
||||||
|
|
||||||
@ -33,6 +34,10 @@ export class UserResponseDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
isEmailVerified!: boolean;
|
isEmailVerified!: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: Gender, nullable: true })
|
||||||
|
gender!: Gender | null;
|
||||||
|
|
||||||
|
|
||||||
constructor(user: User) {
|
constructor(user: User) {
|
||||||
this.id = user.id;
|
this.id = user.id;
|
||||||
this.countryCode = user.countryCode;
|
this.countryCode = user.countryCode;
|
||||||
@ -44,5 +49,6 @@ export class UserResponseDto {
|
|||||||
this.profilePicture = user.profilePicture ? new DocumentMetaResponseDto(user.profilePicture) : null;
|
this.profilePicture = user.profilePicture ? new DocumentMetaResponseDto(user.profilePicture) : null;
|
||||||
this.isEmailVerified = user.isEmailVerified;
|
this.isEmailVerified = user.isEmailVerified;
|
||||||
this.isPhoneVerified = user.isPhoneVerified;
|
this.isPhoneVerified = user.isPhoneVerified;
|
||||||
|
this.gender = (user.customer?.gender as Gender) || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
17
src/card/dtos/responses/guardian-home.response.dto.ts
Normal file
17
src/card/dtos/responses/guardian-home.response.dto.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { TransactionItemResponseDto } from './transaction-item.response.dto';
|
||||||
|
|
||||||
|
export class GuardianHomeResponseDto {
|
||||||
|
@ApiProperty({ example: 2000.0 })
|
||||||
|
availableBalance!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [TransactionItemResponseDto] })
|
||||||
|
recentTransactions!: TransactionItemResponseDto[];
|
||||||
|
|
||||||
|
constructor(availableBalance: number, recentTransactions: TransactionItemResponseDto[]) {
|
||||||
|
this.availableBalance = availableBalance;
|
||||||
|
this.recentTransactions = recentTransactions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1,3 +1,12 @@
|
|||||||
export * from './account-iban.response.dto';
|
export * from './account-iban.response.dto';
|
||||||
export * from './card.response.dto';
|
export * from './card.response.dto';
|
||||||
export * from './child-card.response.dto';
|
export * from './child-card.response.dto';
|
||||||
|
export * from './transaction-item.response.dto';
|
||||||
|
export * from './guardian-home.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';
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
33
src/card/dtos/responses/paged-transactions.response.dto.ts
Normal file
33
src/card/dtos/responses/paged-transactions.response.dto.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { TransactionItemResponseDto } from './transaction-item.response.dto';
|
||||||
|
|
||||||
|
export class PagedTransactionsResponseDto {
|
||||||
|
@ApiProperty({ type: [TransactionItemResponseDto] })
|
||||||
|
items!: TransactionItemResponseDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1 })
|
||||||
|
page!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10 })
|
||||||
|
size!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 45 })
|
||||||
|
total!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
hasMore!: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
items: TransactionItemResponseDto[],
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
24
src/card/dtos/responses/transaction-item.response.dto.ts
Normal file
24
src/card/dtos/responses/transaction-item.response.dto.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ParentTransactionType } from '~/card/enums';
|
||||||
|
|
||||||
|
export class TransactionItemResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
date!: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ example: -50.0 })
|
||||||
|
amountSigned!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ParentTransactionType })
|
||||||
|
type!: ParentTransactionType;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Counterparty display name (child for transfer, source label for top-up)' })
|
||||||
|
counterpartyName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
counterpartyAccountMasked!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
childName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -6,3 +6,4 @@ export * from './card-status.enum';
|
|||||||
export * from './customer-type.enum';
|
export * from './customer-type.enum';
|
||||||
export * from './transaction-scope.enum';
|
export * from './transaction-scope.enum';
|
||||||
export * from './transaction-type.enum';
|
export * from './transaction-type.enum';
|
||||||
|
export * from './parent-transaction-type.enum';
|
||||||
|
|||||||
6
src/card/enums/parent-transaction-type.enum.ts
Normal file
6
src/card/enums/parent-transaction-type.enum.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export enum ParentTransactionType {
|
||||||
|
PARENT_TRANSFER = 'PARENT_TRANSFER',
|
||||||
|
PARENT_TOPUP = 'PARENT_TOPUP',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1 +1,3 @@
|
|||||||
export * from './card.repository';
|
export * from './card.repository';
|
||||||
|
export * from './transaction.repository';
|
||||||
|
export * from './account.repository';
|
||||||
|
|||||||
@ -96,4 +96,76 @@ export class TransactionRepository {
|
|||||||
.orderBy('transaction.transactionDate', 'DESC')
|
.orderBy('transaction.transactionDate', 'DESC')
|
||||||
.getMany();
|
.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findParentTransfers(guardianCustomerId: string, skip: number, take: number): Promise<Transaction[]> {
|
||||||
|
return this.transactionRepository
|
||||||
|
.createQueryBuilder('tx')
|
||||||
|
.innerJoinAndSelect('tx.card', 'card')
|
||||||
|
.innerJoinAndSelect('card.customer', 'childCustomer')
|
||||||
|
.innerJoinAndSelect('card.account', 'account')
|
||||||
|
.where('card.parentId = :guardianCustomerId', { guardianCustomerId })
|
||||||
|
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
|
||||||
|
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
|
||||||
|
.orderBy('tx.transactionDate', 'DESC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(take)
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
findParentTopups(guardianCustomerId: string, skip: number, take: number): Promise<Transaction[]> {
|
||||||
|
return this.transactionRepository
|
||||||
|
.createQueryBuilder('tx')
|
||||||
|
.innerJoinAndSelect('tx.account', 'account')
|
||||||
|
.leftJoinAndSelect('account.cards', 'parentCards')
|
||||||
|
.where('tx.transactionScope = :scope', { scope: TransactionScope.ACCOUNT })
|
||||||
|
.andWhere('parentCards.customerId = :guardianCustomerId', { guardianCustomerId })
|
||||||
|
.orderBy('tx.transactionDate', 'DESC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(take)
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
countParentTransfers(guardianCustomerId: string): Promise<number> {
|
||||||
|
return this.transactionRepository
|
||||||
|
.createQueryBuilder('tx')
|
||||||
|
.innerJoin('tx.card', 'card')
|
||||||
|
.where('card.parentId = :guardianCustomerId', { guardianCustomerId })
|
||||||
|
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
|
||||||
|
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
|
||||||
|
.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
countParentTopups(guardianCustomerId: string): Promise<number> {
|
||||||
|
return this.transactionRepository
|
||||||
|
.createQueryBuilder('tx')
|
||||||
|
.innerJoin('tx.account', 'account')
|
||||||
|
.leftJoin('account.cards', 'parentCards')
|
||||||
|
.where('tx.transactionScope = :scope', { scope: TransactionScope.ACCOUNT })
|
||||||
|
.andWhere('parentCards.customerId = :guardianCustomerId', { guardianCustomerId })
|
||||||
|
.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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1,3 @@
|
|||||||
export * from './card.service';
|
export * from './card.service';
|
||||||
|
export * from './transaction.service';
|
||||||
|
export * from './account.service';
|
||||||
|
|||||||
@ -7,10 +7,19 @@ import {
|
|||||||
CardTransactionWebhookRequest,
|
CardTransactionWebhookRequest,
|
||||||
} from '~/common/modules/neoleap/dtos/requests';
|
} from '~/common/modules/neoleap/dtos/requests';
|
||||||
import { Transaction } from '../entities/transaction.entity';
|
import { Transaction } from '../entities/transaction.entity';
|
||||||
import { CustomerType } from '../enums';
|
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,
|
||||||
|
ParentTransferItemDto,
|
||||||
|
PagedParentTransfersResponseDto,
|
||||||
|
ChildTransferItemDto,
|
||||||
|
PagedChildTransfersResponseDto,
|
||||||
|
} from '../dtos/responses';
|
||||||
|
import { ParentTransactionType } from '../enums';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TransactionService {
|
export class TransactionService {
|
||||||
@ -113,4 +122,158 @@ export class TransactionService {
|
|||||||
|
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getParentConsolidated(
|
||||||
|
guardianCustomerId: string,
|
||||||
|
page: number,
|
||||||
|
size: number,
|
||||||
|
): Promise<TransactionItemResponseDto[]> {
|
||||||
|
const skip = (page - 1) * size;
|
||||||
|
|
||||||
|
const [transfers, topups] = await Promise.all([
|
||||||
|
this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size),
|
||||||
|
this.transactionRepository.findParentTopups(guardianCustomerId, skip, size),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const merged = [...transfers, ...topups].sort(
|
||||||
|
(a, b) => new Date(b.transactionDate).getTime() - new Date(a.transactionDate).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const trimmed = merged.slice(0, size);
|
||||||
|
|
||||||
|
return trimmed.map((t) => this.mapParentItem(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getParentTransactionsPaginated(
|
||||||
|
guardianCustomerId: string,
|
||||||
|
page: number,
|
||||||
|
size: number,
|
||||||
|
type?: ParentTransactionType,
|
||||||
|
): Promise<PagedTransactionsResponseDto> {
|
||||||
|
const skip = (page - 1) * size;
|
||||||
|
|
||||||
|
let transfers: Transaction[] = [];
|
||||||
|
let topups: Transaction[] = [];
|
||||||
|
let transferCount = 0;
|
||||||
|
let topupCount = 0;
|
||||||
|
|
||||||
|
if (!type || type === ParentTransactionType.PARENT_TRANSFER) {
|
||||||
|
[transfers, transferCount] = await Promise.all([
|
||||||
|
this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size),
|
||||||
|
this.transactionRepository.countParentTransfers(guardianCustomerId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!type || type === ParentTransactionType.PARENT_TOPUP) {
|
||||||
|
[topups, topupCount] = await Promise.all([
|
||||||
|
this.transactionRepository.findParentTopups(guardianCustomerId, skip, size),
|
||||||
|
this.transactionRepository.countParentTopups(guardianCustomerId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = transferCount + topupCount;
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
const items = type === ParentTransactionType.PARENT_TRANSFER ? transfers : topups;
|
||||||
|
const mapped = items.map((t) => this.mapParentItem(t));
|
||||||
|
return new PagedTransactionsResponseDto(mapped, page, size, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = [...transfers, ...topups].sort(
|
||||||
|
(a, b) => new Date(b.transactionDate).getTime() - new Date(a.transactionDate).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const paginated = merged.slice(0, size);
|
||||||
|
const mapped = paginated.map((t) => this.mapParentItem(t));
|
||||||
|
|
||||||
|
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.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapParentItem(t: Transaction): TransactionItemResponseDto {
|
||||||
|
const dto = new TransactionItemResponseDto();
|
||||||
|
dto.date = t.transactionDate;
|
||||||
|
|
||||||
|
if (t.transactionType === TransactionType.INTERNAL) {
|
||||||
|
dto.type = ParentTransactionType.PARENT_TRANSFER;
|
||||||
|
dto.amountSigned = -Math.abs(t.transactionAmount);
|
||||||
|
const child = t.card?.customer;
|
||||||
|
dto.counterpartyName = child ? `${child.firstName} ${child.lastName}` : 'Child';
|
||||||
|
dto.childName = dto.counterpartyName;
|
||||||
|
dto.counterpartyAccountMasked = t.card?.account?.accountReference
|
||||||
|
? `****${t.card.account.accountReference.slice(-4)}`
|
||||||
|
: null;
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.type = ParentTransactionType.PARENT_TOPUP;
|
||||||
|
const settlement = Number(t.settlementAmount ?? 0);
|
||||||
|
const txn = Number(t.transactionAmount ?? 0);
|
||||||
|
const creditAmount = settlement > 0 ? settlement : txn;
|
||||||
|
dto.amountSigned = Math.abs(Number.isFinite(creditAmount) ? creditAmount : 0);
|
||||||
|
dto.counterpartyName = 'Top-up';
|
||||||
|
dto.counterpartyAccountMasked = t.accountReference ? `****${t.accountReference.slice(-4)}` : null;
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,12 +56,12 @@ export class AccountTransactionWebhookRequest {
|
|||||||
@ApiProperty({ example: '682' })
|
@ApiProperty({ example: '682' })
|
||||||
currency!: string;
|
currency!: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose({ name: 'Date' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@ApiProperty({ name: 'Date', example: '20241112' })
|
@ApiProperty({ name: 'Date', example: '20241112' })
|
||||||
date!: string;
|
date!: string;
|
||||||
|
|
||||||
@Expose()
|
@Expose({ name: 'Time' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@ApiProperty({ name: 'Time', example: '125340' })
|
@ApiProperty({ name: 'Time', example: '125340' })
|
||||||
time!: string;
|
time!: string;
|
||||||
|
|||||||
50
src/guardian/controllers/guardian-transactions.controller.ts
Normal file
50
src/guardian/controllers/guardian-transactions.controller.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { Roles } from '~/auth/enums';
|
||||||
|
import { IJwtPayload } from '~/auth/interfaces';
|
||||||
|
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
|
||||||
|
import { AccessTokenGuard, RolesGuard } from '~/common/guards';
|
||||||
|
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
|
||||||
|
import { ResponseFactory } from '~/core/utils';
|
||||||
|
import { ParentHomeResponseDto, PagedParentTransfersResponseDto } from '~/card/dtos/responses';
|
||||||
|
import { GuardianTransactionsService } from '../services';
|
||||||
|
|
||||||
|
|
||||||
|
@Controller('guardians/me')
|
||||||
|
@ApiTags('Guardians')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiLangRequestHeader()
|
||||||
|
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||||
|
@AllowedRoles(Roles.GUARDIAN)
|
||||||
|
export class GuardianTransactionsController {
|
||||||
|
constructor(private readonly guardianTxService: GuardianTransactionsService) {}
|
||||||
|
|
||||||
|
@Get('home')
|
||||||
|
@ApiQuery({ name: 'size', required: false, type: Number, example: 5 })
|
||||||
|
@ApiDataResponse(ParentHomeResponseDto)
|
||||||
|
async getHome(
|
||||||
|
@AuthenticatedUser() user: IJwtPayload,
|
||||||
|
@Query('size') size?: number,
|
||||||
|
) {
|
||||||
|
const limit = Math.max(1, Math.min(Number(size) || 5, 20));
|
||||||
|
const res = await this.guardianTxService.getHome(user.sub, limit);
|
||||||
|
return ResponseFactory.data(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('transfers')
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||||
|
@ApiQuery({ name: 'size', required: false, type: Number, example: 10 })
|
||||||
|
@ApiDataResponse(PagedParentTransfersResponseDto)
|
||||||
|
async getTransfers(
|
||||||
|
@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.guardianTxService.getTransfers(user.sub, pageNum, pageSize);
|
||||||
|
return ResponseFactory.data(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
3
src/guardian/controllers/index.ts
Normal file
3
src/guardian/controllers/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './guardian-transactions.controller';
|
||||||
|
|
||||||
|
|
||||||
@ -1,12 +1,18 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { forwardRef } from '@nestjs/common';
|
||||||
|
import { CustomerModule } from '~/customer/customer.module';
|
||||||
|
import { CardModule } from '~/card/card.module';
|
||||||
|
import { GuardianTransactionsController } from './controllers/guardian-transactions.controller';
|
||||||
|
import { GuardianTransactionsService } from './services/guardian-transactions.service';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Guardian } from './entities/guradian.entity';
|
import { Guardian } from './entities/guradian.entity';
|
||||||
import { GuardianRepository } from './repositories';
|
import { GuardianRepository } from './repositories';
|
||||||
import { GuardianService } from './services';
|
import { GuardianService } from './services';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [GuardianService, GuardianRepository],
|
providers: [GuardianService, GuardianRepository, GuardianTransactionsService],
|
||||||
imports: [TypeOrmModule.forFeature([Guardian])],
|
controllers: [GuardianTransactionsController],
|
||||||
|
imports: [TypeOrmModule.forFeature([Guardian]), forwardRef(() => CustomerModule), forwardRef(() => CardModule)],
|
||||||
exports: [GuardianService],
|
exports: [GuardianService],
|
||||||
})
|
})
|
||||||
export class GuardianModule {}
|
export class GuardianModule {}
|
||||||
|
|||||||
42
src/guardian/services/guardian-transactions.service.ts
Normal file
42
src/guardian/services/guardian-transactions.service.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { CustomerService } from '~/customer/services';
|
||||||
|
import { ParentHomeResponseDto, PagedParentTransfersResponseDto } from '~/card/dtos/responses';
|
||||||
|
import { TransactionService } from '~/card/services/transaction.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GuardianTransactionsService {
|
||||||
|
constructor(
|
||||||
|
private readonly customerService: CustomerService,
|
||||||
|
private readonly transactionService: TransactionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getHome(guardianId: string, size: number): Promise<ParentHomeResponseDto> {
|
||||||
|
const parent = await this.customerService.findCustomerById(guardianId);
|
||||||
|
const primaryCard = parent.cards?.[0];
|
||||||
|
|
||||||
|
let availableBalance = 0;
|
||||||
|
if (primaryCard) {
|
||||||
|
const hasLimit = typeof primaryCard.limit === 'number' && !Number.isNaN(primaryCard.limit);
|
||||||
|
const hasBalance = primaryCard.account && typeof primaryCard.account.balance === 'number';
|
||||||
|
if (hasLimit && hasBalance && primaryCard.limit > 0) {
|
||||||
|
availableBalance = Math.min(primaryCard.limit, primaryCard.account.balance);
|
||||||
|
} else if (hasBalance) {
|
||||||
|
availableBalance = primaryCard.account.balance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentTransfers = await this.transactionService.getParentTransfersOnly(guardianId, 1, size);
|
||||||
|
|
||||||
|
return new ParentHomeResponseDto(availableBalance, recentTransfers);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTransfers(
|
||||||
|
guardianId: string,
|
||||||
|
page: number,
|
||||||
|
size: number,
|
||||||
|
): Promise<PagedParentTransfersResponseDto> {
|
||||||
|
return this.transactionService.getParentTransfersPaginated(guardianId, page, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1 +1,2 @@
|
|||||||
export * from './guardian.service';
|
export * from './guardian.service'
|
||||||
|
export * from './guardian-transactions.service'
|
||||||
|
|||||||
@ -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')
|
||||||
@ -157,4 +158,37 @@ export class JuniorController {
|
|||||||
const summary = await this.juniorService.getWeeklySummary(juniorId, user.sub);
|
const summary = await this.juniorService.getWeeklySummary(juniorId, user.sub);
|
||||||
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
@ -221,6 +223,56 @@ export class JuniorService {
|
|||||||
return this.cardService.getWeeklySummary(juniorId);
|
return this.cardService.getWeeklySummary(juniorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user