mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2026-03-10 18:51:45 +00:00
Compare commits
10 Commits
872d231f72
...
feat/paren
| Author | SHA1 | Date | |
|---|---|---|---|
| f6fa74897a | |||
| 183f6b4475 | |||
| 8f601b26ae | |||
| 918b15c315 | |||
| 1830d92cbd | |||
| 44124b9964 | |||
| 454ded627f | |||
| f1484e125b | |||
| df4d2e3c1f | |||
| 11712bedf3 |
@ -1,4 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Gender } from '~/customer/enums';
|
||||
import { DocumentMetaResponseDto } from '~/document/dtos/response';
|
||||
import { User } from '~/user/entities';
|
||||
|
||||
@ -33,6 +34,10 @@ export class UserResponseDto {
|
||||
@ApiProperty()
|
||||
isEmailVerified!: boolean;
|
||||
|
||||
@ApiPropertyOptional({ enum: Gender, nullable: true })
|
||||
gender!: Gender | null;
|
||||
|
||||
|
||||
constructor(user: User) {
|
||||
this.id = user.id;
|
||||
this.countryCode = user.countryCode;
|
||||
@ -44,5 +49,6 @@ export class UserResponseDto {
|
||||
this.profilePicture = user.profilePicture ? new DocumentMetaResponseDto(user.profilePicture) : null;
|
||||
this.isEmailVerified = user.isEmailVerified;
|
||||
this.isPhoneVerified = user.isPhoneVerified;
|
||||
this.gender = (user.customer?.gender as Gender) || null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,6 +34,15 @@ export class CardsController {
|
||||
return ResponseFactory.data(cards.map((card) => new ChildCardResponseDto(card)));
|
||||
}
|
||||
|
||||
@Get('child-cards/:childid')
|
||||
@UseGuards(RolesGuard)
|
||||
@AllowedRoles(Roles.GUARDIAN)
|
||||
@ApiDataResponse(ChildCardResponseDto)
|
||||
async getChildCardById(@Param('childid') childId: string, @AuthenticatedUser() { sub }: IJwtPayload) {
|
||||
const card = await this.cardService.getCardByChildId(sub, childId);
|
||||
return ResponseFactory.data(new ChildCardResponseDto(card));
|
||||
}
|
||||
|
||||
@Get('child-cards/:cardid/embossing-details')
|
||||
@UseGuards(RolesGuard)
|
||||
@AllowedRoles(Roles.GUARDIAN)
|
||||
|
||||
@ -58,7 +58,9 @@ export class CardResponseDto {
|
||||
this.status = card.status;
|
||||
this.statusDescription = CardStatusDescriptionMapper[card.statusDescription][UserLocale.ENGLISH].description;
|
||||
this.balance =
|
||||
card.customerType === CustomerType.CHILD ? Math.min(card.limit, card.account.balance) : card.account.balance;
|
||||
card.customerType === CustomerType.CHILD
|
||||
? Math.min(card.limit, card.account.balance)
|
||||
: card.account.balance - card.account.reservedBalance;
|
||||
this.reservedBalance = card.customerType === CustomerType.PARENT ? card.account.reservedBalance : null;
|
||||
}
|
||||
}
|
||||
|
||||
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,6 @@
|
||||
export * from './account-iban.response.dto';
|
||||
export * from './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';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -22,10 +22,28 @@ export class Account {
|
||||
@Column('varchar', { length: 255, nullable: false, name: 'currency' })
|
||||
currency!: string;
|
||||
|
||||
@Column('decimal', { precision: 10, scale: 2, default: 0.0, name: 'balance' })
|
||||
@Column('decimal', {
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
default: 0.0,
|
||||
name: 'balance',
|
||||
transformer: {
|
||||
to: (value: number) => value,
|
||||
from: (value: string) => parseFloat(value),
|
||||
},
|
||||
})
|
||||
balance!: number;
|
||||
|
||||
@Column('decimal', { precision: 10, scale: 2, default: 0.0, name: 'reserved_balance' })
|
||||
@Column('decimal', {
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
default: 0.0,
|
||||
name: 'reserved_balance',
|
||||
transformer: {
|
||||
to: (value: number) => value,
|
||||
from: (value: string) => parseFloat(value),
|
||||
},
|
||||
})
|
||||
reservedBalance!: number;
|
||||
|
||||
@OneToMany(() => Card, (card) => card.account, { cascade: true })
|
||||
|
||||
@ -32,7 +32,16 @@ export class Transaction {
|
||||
@Column({ name: 'rrn', nullable: true, type: 'varchar' })
|
||||
rrn!: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2, name: 'transaction_amount' })
|
||||
@Column({
|
||||
type: 'decimal',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
name: 'transaction_amount',
|
||||
transformer: {
|
||||
to: (value: number) => value,
|
||||
from: (value: string) => parseFloat(value),
|
||||
},
|
||||
})
|
||||
transactionAmount!: number;
|
||||
|
||||
@Column({ type: 'varchar', name: 'transaction_currency' })
|
||||
|
||||
@ -6,3 +6,4 @@ export * from './card-status.enum';
|
||||
export * from './customer-type.enum';
|
||||
export * from './transaction-scope.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',
|
||||
}
|
||||
|
||||
|
||||
@ -45,6 +45,13 @@ export class CardRepository {
|
||||
return this.cardRepository.findOne({ where: { id }, relations: ['account'] });
|
||||
}
|
||||
|
||||
findCardByChildId(guardianId: string, childId: string): Promise<Card | null> {
|
||||
return this.cardRepository.findOne({
|
||||
where: { parentId: guardianId, customerId: childId, customerType: CustomerType.CHILD },
|
||||
relations: ['account', 'customer', 'customer.user', 'customer.user.profilePicture', 'customer.junior'],
|
||||
});
|
||||
}
|
||||
|
||||
getCardByReferenceNumber(referenceNumber: string): Promise<Card | null> {
|
||||
return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] });
|
||||
}
|
||||
|
||||
@ -1 +1,3 @@
|
||||
export * from './card.repository';
|
||||
export * from './transaction.repository';
|
||||
export * from './account.repository';
|
||||
|
||||
@ -84,4 +84,64 @@ export class TransactionRepository {
|
||||
where: { transactionId, accountReference },
|
||||
});
|
||||
}
|
||||
|
||||
getTransactionsForCardWithinDateRange(juniorId: string, startDate: Date, endDate: Date): Promise<Transaction[]> {
|
||||
return this.transactionRepository
|
||||
.createQueryBuilder('transaction')
|
||||
.innerJoinAndSelect('transaction.card', 'card')
|
||||
.where('card.customerId = :juniorId', { juniorId })
|
||||
.andWhere('transaction.transactionScope = :scope', { scope: TransactionScope.CARD })
|
||||
.andWhere('transaction.transactionType = :type', { type: TransactionType.EXTERNAL })
|
||||
.andWhere('transaction.transactionDate BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||
.orderBy('transaction.transactionDate', 'DESC')
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,7 +65,7 @@ export class AccountService {
|
||||
|
||||
increaseReservedBalance(account: Account, amount: number) {
|
||||
if (account.balance < account.reservedBalance + amount) {
|
||||
throw new UnprocessableEntityException('ACCOUNT.INSUFFICIENT_BALANCE');
|
||||
throw new UnprocessableEntityException('CARD.INSUFFICIENT_BALANCE');
|
||||
}
|
||||
return this.accountRepository.increaseReservedBalance(account.id, amount);
|
||||
}
|
||||
|
||||
@ -63,6 +63,15 @@ export class CardService {
|
||||
|
||||
return this.getCardById(createdCard.id);
|
||||
}
|
||||
|
||||
async getCardByChildId(guardianId: string, childId: string): Promise<Card> {
|
||||
const card = await this.cardRepository.findCardByChildId(guardianId, childId);
|
||||
if (!card) {
|
||||
throw new BadRequestException('CARD.NOT_FOUND');
|
||||
}
|
||||
await this.prepareJuniorImages([card]);
|
||||
return card;
|
||||
}
|
||||
async getCardById(id: string): Promise<Card> {
|
||||
const card = await this.cardRepository.getCardById(id);
|
||||
|
||||
@ -154,6 +163,10 @@ export class CardService {
|
||||
return finalAmount.toNumber();
|
||||
}
|
||||
|
||||
getWeeklySummary(juniorId: string) {
|
||||
return this.transactionService.getWeeklySummary(juniorId);
|
||||
}
|
||||
|
||||
fundIban(iban: string, amount: number) {
|
||||
return this.accountService.fundIban(iban, amount);
|
||||
}
|
||||
|
||||
@ -1 +1,3 @@
|
||||
export * from './card.service';
|
||||
export * from './transaction.service';
|
||||
export * from './account.service';
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import { forwardRef, Inject, Injectable, UnprocessableEntityException } from '@nestjs/common';
|
||||
import Decimal from 'decimal.js';
|
||||
import moment from 'moment';
|
||||
import { Transactional } from 'typeorm-transactional';
|
||||
import {
|
||||
AccountTransactionWebhookRequest,
|
||||
CardTransactionWebhookRequest,
|
||||
} from '~/common/modules/neoleap/dtos/requests';
|
||||
import { Transaction } from '../entities/transaction.entity';
|
||||
import { CustomerType } from '../enums';
|
||||
import { CustomerType, TransactionType } from '../enums';
|
||||
import { TransactionRepository } from '../repositories/transaction.repository';
|
||||
import { AccountService } from './account.service';
|
||||
import { CardService } from './card.service';
|
||||
import { TransactionItemResponseDto, PagedTransactionsResponseDto } from '../dtos/responses';
|
||||
import { ParentTransactionType } from '../enums';
|
||||
|
||||
@Injectable()
|
||||
export class TransactionService {
|
||||
@ -73,4 +76,135 @@ export class TransactionService {
|
||||
|
||||
return existingTransaction;
|
||||
}
|
||||
|
||||
async getWeeklySummary(juniorId: string) {
|
||||
const startOfWeek = moment().startOf('week').toDate();
|
||||
const endOfWeek = moment().endOf('week').toDate();
|
||||
|
||||
const transactions = await this.transactionRepository.getTransactionsForCardWithinDateRange(
|
||||
juniorId,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
);
|
||||
const summary = {
|
||||
startOfWeek: startOfWeek,
|
||||
endOfWeek: endOfWeek,
|
||||
total: 0,
|
||||
monday: 0,
|
||||
tuesday: 0,
|
||||
wednesday: 0,
|
||||
thursday: 0,
|
||||
friday: 0,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
};
|
||||
|
||||
transactions.forEach((transaction) => {
|
||||
const day = moment(transaction.transactionDate).format('dddd').toLowerCase() as
|
||||
| 'monday'
|
||||
| 'tuesday'
|
||||
| 'wednesday'
|
||||
| 'thursday'
|
||||
| 'friday'
|
||||
| 'saturday'
|
||||
| 'sunday';
|
||||
summary[day] += transaction.transactionAmount;
|
||||
});
|
||||
|
||||
summary.total = transactions.reduce((acc, curr) => acc + curr.transactionAmount, 0);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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' })
|
||||
currency!: string;
|
||||
|
||||
@Expose()
|
||||
@Expose({ name: 'Date' })
|
||||
@IsString()
|
||||
@ApiProperty({ name: 'Date', example: '20241112' })
|
||||
date!: string;
|
||||
|
||||
@Expose()
|
||||
@Expose({ name: 'Time' })
|
||||
@IsString()
|
||||
@ApiProperty({ name: 'Time', example: '125340' })
|
||||
time!: string;
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddDeletedAtColumnToJunior1757915357218 implements MigrationInterface {
|
||||
name = 'AddDeletedAtColumnToJunior1757915357218';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "juniors" ADD "deleted_at" TIMESTAMP WITH TIME ZONE`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "juniors" DROP COLUMN "deleted_at"`);
|
||||
}
|
||||
}
|
||||
@ -3,3 +3,4 @@ export * from './1754915164809-create-neoleap-related-entities';
|
||||
export * from './1754915164810-seed-default-avatar';
|
||||
export * from './1757349525708-create-money-requests-table';
|
||||
export * from './1757433339849-add-reservation-amount-to-account-entity';
|
||||
export * from './1757915357218-add-deleted-at-column-to-junior';
|
||||
|
||||
53
src/guardian/controllers/guardian-transactions.controller.ts
Normal file
53
src/guardian/controllers/guardian-transactions.controller.ts
Normal file
@ -0,0 +1,53 @@
|
||||
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 { GuardianHomeResponseDto, PagedTransactionsResponseDto } from '~/card/dtos/responses';
|
||||
import { ParentTransactionType } from '~/card/enums';
|
||||
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(GuardianHomeResponseDto)
|
||||
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('transactions')
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||
@ApiQuery({ name: 'size', required: false, type: Number, example: 10 })
|
||||
@ApiQuery({ name: 'type', required: false, enum: ParentTransactionType })
|
||||
@ApiDataResponse(PagedTransactionsResponseDto)
|
||||
async getTransactions(
|
||||
@AuthenticatedUser() user: IJwtPayload,
|
||||
@Query('page') page?: number,
|
||||
@Query('size') size?: number,
|
||||
@Query('type') type?: ParentTransactionType,
|
||||
) {
|
||||
const pageNum = Math.max(1, Number(page) || 1);
|
||||
const pageSize = Math.max(1, Math.min(Number(size) || 10, 50));
|
||||
const res = await this.guardianTxService.getTransactions(user.sub, pageNum, pageSize, type);
|
||||
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 { 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 { Guardian } from './entities/guradian.entity';
|
||||
import { GuardianRepository } from './repositories';
|
||||
import { GuardianService } from './services';
|
||||
|
||||
@Module({
|
||||
providers: [GuardianService, GuardianRepository],
|
||||
imports: [TypeOrmModule.forFeature([Guardian])],
|
||||
providers: [GuardianService, GuardianRepository, GuardianTransactionsService],
|
||||
controllers: [GuardianTransactionsController],
|
||||
imports: [TypeOrmModule.forFeature([Guardian]), forwardRef(() => CustomerModule), forwardRef(() => CardModule)],
|
||||
exports: [GuardianService],
|
||||
})
|
||||
export class GuardianModule {}
|
||||
|
||||
45
src/guardian/services/guardian-transactions.service.ts
Normal file
45
src/guardian/services/guardian-transactions.service.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CustomerService } from '~/customer/services';
|
||||
import { GuardianHomeResponseDto, PagedTransactionsResponseDto } from '~/card/dtos/responses';
|
||||
import { TransactionItemResponseDto } from '~/card/dtos/responses';
|
||||
import { ParentTransactionType } from '~/card/enums';
|
||||
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<GuardianHomeResponseDto> {
|
||||
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 items: TransactionItemResponseDto[] = await this.transactionService.getParentConsolidated(guardianId, 1, size);
|
||||
|
||||
return new GuardianHomeResponseDto(availableBalance, items);
|
||||
}
|
||||
|
||||
async getTransactions(
|
||||
guardianId: string,
|
||||
page: number,
|
||||
size: number,
|
||||
type?: ParentTransactionType,
|
||||
): Promise<PagedTransactionsResponseDto> {
|
||||
return this.transactionService.getParentTransactionsPaginated(guardianId, page, size, type);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from './guardian.service';
|
||||
export * from './guardian.service'
|
||||
export * from './guardian-transactions.service'
|
||||
|
||||
@ -68,7 +68,8 @@
|
||||
"CIVIL_ID_REQUIRED": "مطلوب بطاقة الهوية المدنية.",
|
||||
"CIVIL_ID_NOT_CREATED_BY_GUARDIAN": "تم تحميل بطاقة الهوية المدنية من قبل شخص آخر غير ولي الأمر.",
|
||||
"CIVIL_ID_ALREADY_EXISTS": "بطاقة الهوية المدنية مستخدمة بالفعل من قبل طفل آخر.",
|
||||
"CANNOT_UPDATE_REGISTERED_USER": "الطفل قد سجل بالفعل. لا يُسمح بتحديث البيانات."
|
||||
"CANNOT_UPDATE_REGISTERED_USER": "الطفل قد سجل بالفعل. لا يُسمح بتحديث البيانات.",
|
||||
"CANNOT_DELETE_REGISTERED_USER": "الطفل قد سجل بالفعل. لا يُسمح بحذف الطفل."
|
||||
},
|
||||
|
||||
"MONEY_REQUEST": {
|
||||
@ -103,6 +104,7 @@
|
||||
},
|
||||
"CARD": {
|
||||
"INSUFFICIENT_BALANCE": "البطاقة لا تحتوي على رصيد كافٍ لإكمال هذا التحويل.",
|
||||
"DOES_NOT_BELONG_TO_GUARDIAN": "البطاقة لا تنتمي إلى ولي الأمر."
|
||||
"DOES_NOT_BELONG_TO_GUARDIAN": "البطاقة لا تنتمي إلى ولي الأمر.",
|
||||
"NOT_FOUND": "لم يتم العثور على البطاقة."
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,7 +67,8 @@
|
||||
"CIVIL_ID_REQUIRED": "Civil ID is required.",
|
||||
"CIVIL_ID_NOT_CREATED_BY_GUARDIAN": "The civil ID document was not uploaded by the guardian.",
|
||||
"CIVIL_ID_ALREADY_EXISTS": "The civil ID is already used by another junior.",
|
||||
"CANNOT_UPDATE_REGISTERED_USER": "The junior has already registered. Updating details is not allowed."
|
||||
"CANNOT_UPDATE_REGISTERED_USER": "The junior has already registered. Updating details is not allowed.",
|
||||
"CANNOT_DELETE_REGISTERED_USER": "The junior has already registered. Deleting the junior is not allowed."
|
||||
},
|
||||
|
||||
"MONEY_REQUEST": {
|
||||
@ -102,6 +103,7 @@
|
||||
},
|
||||
"CARD": {
|
||||
"INSUFFICIENT_BALANCE": "The card does not have sufficient balance to complete this transfer.",
|
||||
"DOES_NOT_BELONG_TO_GUARDIAN": "The card does not belong to the guardian."
|
||||
"DOES_NOT_BELONG_TO_GUARDIAN": "The card does not belong to the guardian.",
|
||||
"NOT_FOUND": "The card was not found."
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,16 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { Roles } from '~/auth/enums';
|
||||
import { IJwtPayload } from '~/auth/interfaces';
|
||||
@ -20,6 +32,7 @@ import {
|
||||
ThemeResponseDto,
|
||||
TransferToJuniorResponseDto,
|
||||
} from '../dtos/response';
|
||||
import { WeeklySummaryResponseDto } from '../dtos/response/weekly-summary.response.dto';
|
||||
import { JuniorService } from '../services';
|
||||
|
||||
@Controller('juniors')
|
||||
@ -83,6 +96,14 @@ export class JuniorController {
|
||||
return ResponseFactory.data(new JuniorResponseDto(junior));
|
||||
}
|
||||
|
||||
@Delete(':juniorId')
|
||||
@UseGuards(RolesGuard)
|
||||
@AllowedRoles(Roles.GUARDIAN)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteJunior(@AuthenticatedUser() user: IJwtPayload, @Param('juniorId', CustomParseUUIDPipe) juniorId: string) {
|
||||
await this.juniorService.deleteJunior(juniorId, user.sub);
|
||||
}
|
||||
|
||||
@Post('set-theme')
|
||||
@UseGuards(RolesGuard)
|
||||
@AllowedRoles(Roles.JUNIOR)
|
||||
@ -124,4 +145,16 @@ export class JuniorController {
|
||||
|
||||
return ResponseFactory.data(new TransferToJuniorResponseDto(newAmount));
|
||||
}
|
||||
|
||||
@Get(':juniorId/weekly-summary')
|
||||
@UseGuards(RolesGuard)
|
||||
@AllowedRoles(Roles.GUARDIAN)
|
||||
@ApiDataResponse(WeeklySummaryResponseDto)
|
||||
async getWeeklySummary(
|
||||
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
|
||||
@AuthenticatedUser() user: IJwtPayload,
|
||||
) {
|
||||
const summary = await this.juniorService.getWeeklySummary(juniorId, user.sub);
|
||||
return ResponseFactory.data(summary);
|
||||
}
|
||||
}
|
||||
|
||||
31
src/junior/dtos/response/weekly-summary.response.dto.ts
Normal file
31
src/junior/dtos/response/weekly-summary.response.dto.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class WeeklySummaryResponseDto {
|
||||
@ApiProperty({ description: 'Start date of the week', example: '2023-10-01' })
|
||||
startOfWeek!: Date;
|
||||
@ApiProperty({ description: 'End date of the week', example: '2023-10-07' })
|
||||
endOfWeek!: Date;
|
||||
@ApiProperty({ description: 'Total amount spent in the week', example: 350 })
|
||||
total!: number;
|
||||
|
||||
@ApiProperty({ description: 'Amount spent on Sunday', example: 50 })
|
||||
sunday!: number;
|
||||
|
||||
@ApiProperty({ description: 'Amount spent on Monday', example: 30 })
|
||||
monday!: number;
|
||||
|
||||
@ApiProperty({ description: 'Amount spent on Tuesday', example: 20 })
|
||||
tuesday!: number;
|
||||
|
||||
@ApiProperty({ description: 'Amount spent on Wednesday', example: 40 })
|
||||
wednesday!: number;
|
||||
|
||||
@ApiProperty({ description: 'Amount spent on Thursday', example: 60 })
|
||||
thursday!: number;
|
||||
|
||||
@ApiProperty({ description: 'Amount spent on Friday', example: 70 })
|
||||
friday!: number;
|
||||
|
||||
@ApiProperty({ description: 'Amount spent on Saturday', example: 80 })
|
||||
saturday!: number;
|
||||
}
|
||||
@ -2,6 +2,7 @@ import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
@ -49,4 +50,7 @@ export class Junior extends BaseEntity {
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamp with time zone', nullable: true })
|
||||
deletedAt!: Date | null;
|
||||
}
|
||||
|
||||
@ -65,4 +65,8 @@ export class JuniorRepository {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
softDelete(juniorId: string) {
|
||||
return this.juniorRepository.softDelete({ id: juniorId });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import { Transactional } from 'typeorm-transactional';
|
||||
import { Roles } from '~/auth/enums';
|
||||
import { CardService } from '~/card/services';
|
||||
@ -183,6 +184,43 @@ export class JuniorService {
|
||||
return this.cardService.transferToChild(juniorId, body.amount);
|
||||
}
|
||||
|
||||
async deleteJunior(juniorId: string, guardianId: string) {
|
||||
const doesBelong = await this.doesJuniorBelongToGuardian(guardianId, juniorId);
|
||||
|
||||
if (!doesBelong) {
|
||||
this.logger.error(`Junior ${juniorId} does not belong to guardian ${guardianId}`);
|
||||
throw new BadRequestException('JUNIOR.NOT_BELONG_TO_GUARDIAN');
|
||||
}
|
||||
|
||||
const hasPassword = await this.userService.findUser({ id: juniorId, password: Not(IsNull()) });
|
||||
|
||||
if (hasPassword) {
|
||||
this.logger.error(`Cannot delete junior ${juniorId} with registered user`);
|
||||
throw new BadRequestException('JUNIOR.CANNOT_DELETE_REGISTERED_USER');
|
||||
}
|
||||
|
||||
const { affected } = await this.juniorRepository.softDelete(juniorId);
|
||||
|
||||
if (affected === 0) {
|
||||
this.logger.error(`Junior ${juniorId} not found`);
|
||||
throw new BadRequestException('JUNIOR.NOT_FOUND');
|
||||
}
|
||||
|
||||
this.logger.log(`Junior ${juniorId} deleted successfully`);
|
||||
}
|
||||
|
||||
getWeeklySummary(juniorId: string, guardianId: string) {
|
||||
const doesBelong = this.doesJuniorBelongToGuardian(guardianId, juniorId);
|
||||
|
||||
if (!doesBelong) {
|
||||
this.logger.error(`Junior ${juniorId} does not belong to guardian ${guardianId}`);
|
||||
throw new BadRequestException('JUNIOR.NOT_BELONG_TO_GUARDIAN');
|
||||
}
|
||||
|
||||
this.logger.log(`Getting weekly summary for junior ${juniorId}`);
|
||||
return this.cardService.getWeeklySummary(juniorId);
|
||||
}
|
||||
|
||||
private async prepareJuniorImages(juniors: Junior[]) {
|
||||
this.logger.log(`Preparing junior images`);
|
||||
await Promise.all(
|
||||
|
||||
Reference in New Issue
Block a user