Compare commits

...

7 Commits

23 changed files with 475 additions and 8 deletions

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
export enum ParentTransactionType {
PARENT_TRANSFER = 'PARENT_TRANSFER',
PARENT_TOPUP = 'PARENT_TOPUP',
}

View File

@ -1 +1,3 @@
export * from './card.repository';
export * from './transaction.repository';
export * from './account.repository';

View File

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

View File

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

View File

@ -1 +1,3 @@
export * from './card.service';
export * from './transaction.service';
export * from './account.service';

View File

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

View File

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

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

View File

@ -0,0 +1,3 @@
export * from './guardian-transactions.controller';

View File

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

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

View File

@ -1 +1,2 @@
export * from './guardian.service';
export * from './guardian.service'
export * from './guardian-transactions.service'

View File

@ -32,6 +32,7 @@ import {
ThemeResponseDto,
TransferToJuniorResponseDto,
} from '../dtos/response';
import { WeeklySummaryResponseDto } from '../dtos/response/weekly-summary.response.dto';
import { JuniorService } from '../services';
@Controller('juniors')
@ -144,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);
}
}

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

View File

@ -209,6 +209,18 @@ export class JuniorService {
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(