import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import Decimal from 'decimal.js'; import { Transactional } from 'typeorm-transactional'; import { NOTIFICATION_EVENTS } from '~/common/modules/notification/constants/event-names.constant'; import { ICardBlockedEvent, ICardCreatedEvent } from '~/common/modules/notification/interfaces/notification-events.interface'; import { AccountCardStatusChangedWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; import { NeoLeapService } from '~/common/modules/neoleap/services'; import { Customer } from '~/customer/entities'; import { KycStatus } from '~/customer/enums'; import { CustomerService } from '~/customer/services'; import { OciService } from '~/document/services'; import { Card } from '../entities'; import { CardColors, CardStatus } from '../enums'; import { CardStatusMapper } from '../mappers/card-status.mapper'; import { CardRepository } from '../repositories'; import { AccountService } from './account.service'; import { TransactionService } from './transaction.service'; @Injectable() export class CardService { private readonly logger = new Logger(CardService.name); constructor( private readonly cardRepository: CardRepository, private readonly accountService: AccountService, private readonly ociService: OciService, @Inject(forwardRef(() => TransactionService)) private readonly transactionService: TransactionService, @Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService, @Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService, private readonly eventEmitter: EventEmitter2, ) {} @Transactional() async createCard(customerId: string): Promise { const customer = await this.customerService.findCustomerById(customerId); if (customer.kycStatus !== KycStatus.APPROVED) { throw new BadRequestException('CUSTOMER.KYC_NOT_APPROVED'); } if (!customer.neoleapExternalCustomerId) { throw new BadRequestException('CUSTOMER.KYC_NOT_COMPLETED'); } if (customer.cards.length > 0) { throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD'); } // Validate required fields for card creation const missingFields = []; if (!customer.nationalId) missingFields.push('nationalId'); if (!customer.dateOfBirth) missingFields.push('dateOfBirth'); if (!customer.nationalIdExpiry) missingFields.push('nationalIdExpiry'); if (missingFields.length > 0) { throw new BadRequestException( `CUSTOMER.MISSING_REQUIRED_FIELDS: ${missingFields.join(', ')}. Please complete your profile.` ); } const data = await this.neoleapService.createApplication(customer); const account = await this.accountService.createAccount(data); const createdCard = await this.cardRepository.createCard(customerId, account.id, data); const cardWithRelations = await this.getCardById(createdCard.id); const event: ICardCreatedEvent = { card: cardWithRelations, timestamp: new Date(), }; this.eventEmitter.emit(NOTIFICATION_EVENTS.CARD_CREATED, event); this.logger.log(`Emitted CARD_CREATED event for card ${cardWithRelations.id}`); return cardWithRelations; } async getChildCards(guardianId: string): Promise { const cards = await this.cardRepository.findChildCardsForGuardian(guardianId); await this.prepareJuniorImages(cards); return cards; } async createCardForChild(parentCustomer: Customer, childCustomer: Customer, cardColor: CardColors, cardPin: string) { const data = await this.neoleapService.createChildCard(parentCustomer, childCustomer, cardPin); const createdCard = await this.cardRepository.createCard( childCustomer.id, parentCustomer.cards[0].account.id, data, cardColor, parentCustomer.id, ); const cardWithRelations = await this.getCardById(createdCard.id); const event: ICardCreatedEvent = { card: cardWithRelations, timestamp: new Date(), }; this.eventEmitter.emit(NOTIFICATION_EVENTS.CARD_CREATED, event); this.logger.log(`Emitted CARD_CREATED event for child card ${cardWithRelations.id}`); return cardWithRelations; } async getCardByChildId(guardianId: string, childId: string): Promise { 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 { const card = await this.cardRepository.getCardById(id); if (!card) { throw new BadRequestException('CARD.NOT_FOUND'); } return card; } async getCardByReferenceNumber(referenceNumber: string): Promise { const card = await this.cardRepository.getCardByReferenceNumber(referenceNumber); if (!card) { throw new BadRequestException('CARD.NOT_FOUND'); } return card; } async getCardByVpan(vpan: string): Promise { const card = await this.cardRepository.getCardByVpan(vpan); if (!card) { throw new BadRequestException('CARD.NOT_FOUND'); } return card; } async getCardByCustomerId(customerId: string): Promise { const card = await this.cardRepository.getCardByCustomerId(customerId); if (!card) { throw new BadRequestException('CARD.NOT_FOUND'); } return card; } async updateCardStatus(body: AccountCardStatusChangedWebhookRequest) { const card = await this.getCardByVpan(body.cardId); const previousStatus = card.status; const { description, status } = CardStatusMapper[body.newStatus] || CardStatusMapper['99']; await this.cardRepository.updateCardStatus(card.id, status, description); if (status === CardStatus.BLOCKED) { const updatedCard = await this.getCardById(card.id); const event: ICardBlockedEvent = { card: updatedCard, previousStatus, blockReason: description, timestamp: new Date(), }; this.eventEmitter.emit(NOTIFICATION_EVENTS.CARD_BLOCKED, event); this.logger.log(`Emitted CARD_BLOCKED event for card ${updatedCard.id}`); } return { id: card.id, status, description }; } async getEmbossingInformation(customerId: string) { const card = await this.getCardByCustomerId(customerId); return this.neoleapService.getEmbossingInformation(card); } async getChildCardEmbossingInformation(cardId: string, guardianId: string) { const card = await this.getCardById(cardId); if (card.parentId !== guardianId) { throw new BadRequestException('CARD.DOES_NOT_BELONG_TO_GUARDIAN'); } return this.neoleapService.getEmbossingInformation(card); } async updateCardLimit(cardId: string, newLimit: number) { const { affected } = await this.cardRepository.updateCardLimit(cardId, newLimit); if (affected === 0) { throw new BadRequestException('CARD.NOT_FOUND'); } } async getIbanInformation(customerId: string) { const account = await this.accountService.getAccountByCustomerId(customerId); return account.iban; } @Transactional() async transferToChild(juniorId: string, amount: number) { const card = await this.getCardByCustomerId(juniorId); this.logger.debug(`Transfer to child - juniorId: ${juniorId}, parentId: ${card.parentId}, cardId: ${card.id}`); this.logger.debug(`Card account - balance: ${card.account.balance}, reserved: ${card.account.reservedBalance}`); const fundingAccount = card.parentId ? await this.accountService.getAccountByCustomerId(card.parentId) : card.account; this.logger.debug(`Funding account - balance: ${fundingAccount.balance}, reserved: ${fundingAccount.reservedBalance}, available: ${fundingAccount.balance - fundingAccount.reservedBalance}`); this.logger.debug(`Amount requested: ${amount}`); if (amount > fundingAccount.balance - fundingAccount.reservedBalance) { this.logger.error(`Insufficient balance - requested: ${amount}, available: ${fundingAccount.balance - fundingAccount.reservedBalance}`); throw new BadRequestException('CARD.INSUFFICIENT_BALANCE'); } // Validate card reference exists if (!card.cardReference) { this.logger.error(`Card ${card.id} does not have a cardReference`); throw new BadRequestException('CARD.INVALID_CARD_REFERENCE'); } // Validate card limit is a valid number const cardLimit = card.limit || 0; if (isNaN(cardLimit) || cardLimit < 0) { this.logger.error(`Card ${card.id} has invalid limit: ${cardLimit}`); throw new BadRequestException('CARD.INVALID_CARD_LIMIT'); } const finalAmount = Decimal(amount).plus(cardLimit); const finalAmountNumber = finalAmount.toNumber(); // Validate final amount is positive if (finalAmountNumber <= 0 || !isFinite(finalAmountNumber)) { this.logger.error(`Invalid final amount calculated: ${finalAmountNumber} (amount: ${amount}, limit: ${cardLimit})`); throw new BadRequestException('CARD.INVALID_AMOUNT'); } this.logger.debug(`Updating card control - cardReference: ${card.cardReference}, finalAmount: ${finalAmountNumber}`); // First, ensure all external operations succeed before creating transaction await Promise.all([ this.neoleapService.updateCardControl(card.cardReference, finalAmountNumber), this.updateCardLimit(card.id, finalAmountNumber), this.accountService.increaseReservedBalance(fundingAccount, amount), // Increase child account balance this.accountService.creditAccountBalance(card.account.accountReference, amount), // Decrease parent account balance (only if parent is funding) card.parentId ? this.accountService.decreaseAccountBalance(fundingAccount.accountReference, amount) : Promise.resolve(), ]); // Only create transaction and emit event after all operations succeed await this.transactionService.createInternalChildTransaction(card.id, amount); return finalAmount.toNumber(); } getWeeklySummary(juniorId: string, startDate?: Date, endDate?: Date) { return this.transactionService.getWeeklySummary(juniorId, startDate, endDate); } fundIban(iban: string, amount: number) { return this.accountService.fundIban(iban, amount); } private async prepareJuniorImages(cards: Card[]) { this.logger.log(`Preparing junior images`); await Promise.all( cards.map(async (card) => { const profilePicture = card.customer?.user?.profilePicture; if (profilePicture) { profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture); } }), ); } }