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, TransactionType } from '../enums'; import { TransactionRepository } from '../repositories/transaction.repository'; import { AccountService } from './account.service'; import { CardService } from './card.service'; import { TransactionItemResponseDto, PagedTransactionsResponseDto, ParentTransferItemDto, PagedParentTransfersResponseDto, ChildTransferItemDto, PagedChildTransfersResponseDto, } from '../dtos/responses'; import { ParentTransactionType } from '../enums'; @Injectable() export class TransactionService { constructor( private readonly transactionRepository: TransactionRepository, private readonly accountService: AccountService, @Inject(forwardRef(() => CardService)) private readonly cardService: CardService, ) {} @Transactional() async createCardTransaction(body: CardTransactionWebhookRequest) { const card = await this.cardService.getCardByVpan(body.cardId); const existingTransaction = await this.findExistingTransaction(body.transactionId, card.account.accountReference); if (existingTransaction) { throw new UnprocessableEntityException('TRANSACTION.ALREADY_EXISTS'); } const transaction = await this.transactionRepository.createCardTransaction(card, body); const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees); if (card.customerType === CustomerType.CHILD) { if (card.parentId) { const parentAccount = await this.accountService.getAccountByCustomerId(card.parentId); await Promise.all([ this.accountService.decreaseAccountBalance(parentAccount.accountReference, total.toNumber()), this.accountService.decrementReservedBalance(parentAccount, total.toNumber()), ]); } else { await Promise.all([ this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()), this.accountService.decrementReservedBalance(card.account, total.toNumber()), ]); } } else { await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()); } return transaction; } @Transactional() async createAccountTransaction(body: AccountTransactionWebhookRequest) { const account = await this.accountService.getAccountByAccountNumber(body.accountId); const existingTransaction = await this.findExistingTransaction(body.transactionId, account.accountReference); if (existingTransaction) { throw new UnprocessableEntityException('TRANSACTION.ALREADY_EXISTS'); } const transaction = await this.transactionRepository.createAccountTransaction(account, body); await this.accountService.creditAccountBalance(account.accountReference, body.amount); return transaction; } async createInternalChildTransaction(cardId: string, amount: number) { const card = await this.cardService.getCardById(cardId); const transaction = await this.transactionRepository.createInternalChildTransaction(card, amount); return transaction; } private async findExistingTransaction(transactionId: string, accountReference: string): Promise { const existingTransaction = await this.transactionRepository.findTransactionByReference( transactionId, accountReference, ); return existingTransaction; } async getWeeklySummary(juniorId: string, startDate?: Date, endDate?: Date) { let startOfWeek: Date; let endOfWeek: Date; if (startDate && endDate) { startOfWeek = startDate; endOfWeek = endDate; } else { const now = moment(); const dayOfWeek = now.day(); startOfWeek = moment().subtract(dayOfWeek, 'days').startOf('day').toDate(); endOfWeek = moment().add(6 - dayOfWeek, 'days').endOf('day').toDate(); } const transactions = await this.transactionRepository.getTransactionsForCardWithinDateRange( 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 { 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 { 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 { 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 { 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 { 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 { const skip = (page - 1) * size; const [transfers, total] = await Promise.all([ this.transactionRepository.findTransfersToJunior(juniorId, skip, size), this.transactionRepository.countTransfersToJunior(juniorId), ]); const items = transfers.map((t) => this.mapToChildTransferItem(t)); return new PagedChildTransfersResponseDto(items, page, size, total); } private mapToParentTransferItem(t: Transaction): ParentTransferItemDto { const child = t.card?.customer; const currency = t.transactionCurrency === '682' ? 'SAR' : t.transactionCurrency; return { date: t.transactionDate, amount: Math.abs(t.transactionAmount), currency, childName: child ? `${child.firstName} ${child.lastName}` : 'Child', }; } private mapToChildTransferItem(t: Transaction): ChildTransferItemDto { const amount = Math.abs(t.transactionAmount); const currency = t.transactionCurrency === '682' ? 'SAR' : t.transactionCurrency; return { date: t.transactionDate, amount, currency, message: `You received {{amount}} {{currency}} from your parent.`, }; } async getChildSpendingHistory(juniorId: string, startUtc: Date, endUtc: Date) { const transactions = await this.transactionRepository.getTransactionsForCardWithinDateRange( juniorId, startUtc, endUtc, ); const { SpendingHistoryItemDto, SpendingHistoryResponseDto } = await import('../dtos/responses'); const items = transactions.map((t) => new SpendingHistoryItemDto(t)); return new SpendingHistoryResponseDto(items); } async getTransactionDetail(transactionId: string, juniorId: string) { const transaction = await this.transactionRepository.findTransactionById(transactionId, juniorId); if (!transaction) { throw new UnprocessableEntityException('TRANSACTION.NOT_FOUND'); } const { TransactionDetailResponseDto } = await import('../dtos/responses'); return new TransactionDetailResponseDto(transaction); } private mapParentItem(t: Transaction): TransactionItemResponseDto { 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; } }