Compare commits

...

11 Commits

Author SHA1 Message Date
1822f074c6 fix: update phone number change instructions in help/support FAQs for clarity and localization 2026-02-01 14:24:35 +03:00
db946b9531 fix build error 2026-01-25 14:10:46 +03:00
25ede3c9e7 feat(lookup): add localized help/support FAQs 2026-01-25 13:15:59 +03:00
47b825c4b2 Merge pull request #89 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: enhance card service and transaction notification for shared ac…
2026-01-20 16:28:42 +03:00
f5c3b03264 feat: enhance card service and transaction notification for shared account handling
- Updated CardService to differentiate between shared and separate accounts during card control updates, optimizing balance allocation.
- Enhanced TransactionNotificationListener to accurately reflect balance based on account structure for internal transfers and top-ups.
- Improved logging for better traceability of account operations and balance calculations.
2026-01-20 16:27:32 +03:00
6a250efd5e Merge pull request #88 from Zod-Alkhair/feature/notification-system-fcm-registration
feat: enhance transaction notification listener for internal transfer…
2026-01-20 15:21:45 +03:00
a09b84e475 feat: enhance transaction notification listener for internal transfer support
- Updated TransactionNotificationListener to differentiate between internal transfers and external top-ups for child accounts.
- Added new notification scopes and messages for internal transfers from parent to child.
- Improved balance retrieval logic to ensure accurate account balances are displayed in notifications.
- Enhanced localization support by adding relevant keys for internal transfer notifications in both English and Arabic.
2026-01-20 15:20:58 +03:00
604cb7ce25 Merge pull request #87 from Zod-Alkhair/feature/notification-system-fcm-registration
fix the messages
2026-01-20 14:42:03 +03:00
4305c4b75f fix the messages 2026-01-20 14:40:16 +03:00
ef5572440c Merge pull request #86 from Zod-Alkhair/fix/spending-history-junior-id-query
fix: correct junior ID to customer ID mapping in transaction queries
2026-01-20 12:50:34 +03:00
64623c7cea fix: correct junior ID to customer ID mapping in transaction queries
Fixed spending history and related transaction queries that were incorrectly
using juniorId as customerId. The queries now properly join through the
Customer -> Junior relationship to filter by junior ID.

Affected methods:
- getTransactionsForCardWithinDateRange (spending history)
- findTransfersToJunior (transfers list)
- countTransfersToJunior (transfers count)
- findTransactionById (transaction details)

This fixes the spending history endpoint which was returning empty results
due to ID mismatch between Junior entity ID and Customer entity ID.

Performance impact: Minimal (~1-2ms overhead from additional joins on
indexed foreign keys). The queries now return correct results instead of
0 results.
2026-01-20 12:39:10 +03:00
14 changed files with 410 additions and 104 deletions

View File

@ -92,7 +92,9 @@ export class TransactionRepository {
return this.transactionRepository
.createQueryBuilder('transaction')
.innerJoinAndSelect('transaction.card', 'card')
.where('card.customerId = :juniorId', { juniorId })
.innerJoin('card.customer', 'customer')
.innerJoin('customer.junior', 'junior')
.where('junior.id = :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 })
@ -153,7 +155,9 @@ export class TransactionRepository {
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.card', 'card')
.innerJoinAndSelect('card.account', 'account')
.where('card.customerId = :juniorId', { juniorId })
.innerJoin('card.customer', 'customer')
.innerJoin('customer.junior', 'junior')
.where('junior.id = :juniorId', { juniorId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.orderBy('tx.transactionDate', 'DESC')
@ -166,7 +170,9 @@ export class TransactionRepository {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoin('tx.card', 'card')
.where('card.customerId = :juniorId', { juniorId })
.innerJoin('card.customer', 'customer')
.innerJoin('customer.junior', 'junior')
.where('junior.id = :juniorId', { juniorId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.getCount();
@ -176,8 +182,10 @@ export class TransactionRepository {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.card', 'card')
.innerJoin('card.customer', 'customer')
.innerJoin('customer.junior', 'junior')
.where('tx.id = :transactionId', { transactionId })
.andWhere('card.customerId = :juniorId', { juniorId })
.andWhere('junior.id = :juniorId', { juniorId })
.getOne();
}
}

View File

@ -240,16 +240,37 @@ export class CardService {
this.logger.debug(`Updating card control - cardReference: ${card.cardReference}, finalAmount: ${finalAmountNumber}`);
// Check if child and parent share the same account
const isSharedAccount = card.parentId && card.account.id === fundingAccount.id;
this.logger.debug(
`Account structure - Child account: ${card.account.id}, Parent account: ${fundingAccount.id}, ` +
`Shared: ${isSharedAccount ? 'YES' : 'NO'}`
);
// 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(),
]);
if (isSharedAccount) {
// Shared account: Only update card limit and reserved balance
// Money is already in the shared account, just allocate it to the child
this.logger.debug(`Shared account detected - only updating card limit and reserved balance`);
await Promise.all([
this.neoleapService.updateCardControl(card.cardReference, finalAmountNumber),
this.updateCardLimit(card.id, finalAmountNumber),
this.accountService.increaseReservedBalance(fundingAccount, amount),
]);
} else {
// Separate accounts: Transfer money from parent to child
this.logger.debug(`Separate accounts - transferring money from parent to child`);
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
this.accountService.decreaseAccountBalance(fundingAccount.accountReference, amount),
]);
}
// Only create transaction and emit event after all operations succeed
await this.transactionService.createInternalChildTransaction(card.id, amount);

View File

@ -1,9 +1,12 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { BadRequestException, Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { AccessTokenGuard } from '~/common/guards';
import { ApiDataArrayResponse } from '~/core/decorators';
import { ApiDataArrayResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { HelpSupportFaqResponseDto } from '../dtos/response';
import { LookupLanguage } from '../enums';
import { LookupService } from '../services';
@Controller('lookup')
@ -29,4 +32,24 @@ export class LookupController {
return ResponseFactory.dataArray(avatars.map((avatar) => new DocumentMetaResponseDto(avatar)));
}
@UseGuards(AccessTokenGuard)
@Get('help-support-faqs')
@ApiDataArrayResponse(HelpSupportFaqResponseDto)
@ApiLangRequestHeader()
getHelpSupportFaqs(@Req() request: Request) {
const header = request.headers['accept-language'];
const lang = Array.isArray(header) ? header[0] : header;
const normalized = (lang || '').trim().toLowerCase();
if (normalized && normalized !== LookupLanguage.EN && normalized !== LookupLanguage.AR) {
throw new BadRequestException('Accept-Language must be "en" or "ar".');
}
const faqs = this.lookupService.getHelpSupportFaqs(
normalized === LookupLanguage.AR ? LookupLanguage.AR : LookupLanguage.EN,
);
return ResponseFactory.dataArray(faqs);
}
}

View File

@ -0,0 +1,72 @@
[
{
"id": "transfer_failed_or_delayed",
"question_en": "What happens if a transfer fails or is delayed?",
"answer_en": "If a transfer fails, the amount will not be deducted from your balance. If a transfer is delayed, it will usually complete within a short time. If the issue continues, please contact customer support.",
"question_ar": "ماذا يحدث إذا فشل التحويل أو تأخر؟",
"answer_ar": "إذا فشل التحويل، لن يُخصم المبلغ من رصيدك. إذا تأخر التحويل، فعادةً يكتمل خلال وقت قصير. إذا استمرت المشكلة، يرجى التواصل مع دعم العملاء."
},
{
"id": "same_email_or_phone_multiple_accounts",
"question_en": "Can I use the same email or phone number for multiple accounts?",
"answer_en": "No. Each email address and phone number can be used for only one account.",
"question_ar": "هل يمكنني استخدام نفس البريد الإلكتروني أو رقم الهاتف لعدة حسابات؟",
"answer_ar": "لا. يمكن استخدام كل بريد إلكتروني ورقم هاتف لحساب واحد فقط."
},
{
"id": "update_profile_details",
"question_en": "How do I update my profile details?",
"answer_en": "Go to Profile - Edit Profile, update your details, and save the changes.",
"question_ar": "كيف يمكنني تحديث بيانات ملفي الشخصي؟",
"answer_ar": "انتقل إلى الملف الشخصي - تعديل الملف الشخصي، حدّث بياناتك ثم احفظ التغييرات."
},
{
"id": "change_email_address",
"question_en": "How do I change my email address?",
"answer_en": "You can update your email or phone number from Profile - Edit Profile. You may be asked to re-login for security reasons.",
"question_ar": "كيف أغيّر عنوان بريدي الإلكتروني؟",
"answer_ar": "يمكنك تحديث بريدك الإلكتروني أو رقم هاتفك من الملف الشخصي - تعديل الملف الشخصي. قد يُطلب منك تسجيل الدخول مرة أخرى لأسباب أمنية."
},
{
"id": "change_phone_number",
"question_en": "How do I change my phone number?",
"answer_en": "At the moment, phone numbers cant be changed directly in the app. Please contact our support team, and theyll assist you with updating it.",
"question_ar": "كيف أغيّر رقم هاتفي؟",
"answer_ar": "حاليًا لا يمكن تغيير أرقام الهواتف مباشرةً داخل التطبيق. يرجى التواصل مع فريق الدعم، وسيساعدونك في تحديثه."
},
{
"id": "activate_card",
"question_en": "How do I activate my card?",
"answer_en": "Transfer 20 SAR to activate your card. Once completed, your card will become active.",
"question_ar": "كيف أقوم بتفعيل بطاقتي؟",
"answer_ar": "حوّل 20 ريالًا سعوديًا لتفعيل بطاقتك. بعد إتمام التحويل، ستصبح البطاقة نشطة."
},
{
"id": "why_transfer_20_sar",
"question_en": "Why do I need to transfer 20 SAR to activate the card?",
"answer_en": "This amount is required to activate the card and enable usage.",
"question_ar": "لماذا يجب علي تحويل 20 ريالًا سعوديًا لتفعيل البطاقة؟",
"answer_ar": "هذا المبلغ مطلوب لتفعيل البطاقة وتمكين استخدامها."
},
{
"id": "where_activation_amount_go",
"question_en": "Where does the activation amount go?",
"answer_en": "The activation amount is collected by the company as part of the card activation process.",
"question_ar": "أين يذهب مبلغ التفعيل؟",
"answer_ar": "يتم تحصيل مبلغ التفعيل من قبل الشركة كجزء من عملية تفعيل البطاقة."
},
{
"id": "top_up_external_bank",
"question_en": "Can I top up from an external bank account?",
"answer_en": "Yes. You can transfer funds from your external bank to your ZOD account",
"question_ar": "هل يمكنني الشحن من حساب بنكي خارجي؟",
"answer_ar": "نعم. يمكنك تحويل الأموال من بنكك الخارجي إلى حسابك في زد."
},
{
"id": "data_security",
"question_en": "Is my data secure in the ZOD app?",
"answer_en": "Yes. We use secure systems and encryption to protect your data.",
"question_ar": "هل بياناتي آمنة في تطبيق زد؟",
"answer_ar": "نعم. نستخدم أنظمة آمنة وتشفيرًا لحماية بياناتك."
}
]

View File

@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
export class HelpSupportFaqResponseDto {
@ApiProperty()
id!: string;
@ApiProperty()
question!: string;
@ApiProperty()
answer!: string;
constructor(item: { id: string; question: string; answer: string }) {
this.id = item.id;
this.question = item.question;
this.answer = item.answer;
}
}

View File

@ -0,0 +1 @@
export * from './help-support-faq.response.dto';

View File

@ -0,0 +1 @@
export * from './lookup-language.enum';

View File

@ -0,0 +1,4 @@
export enum LookupLanguage {
EN = 'en',
AR = 'ar',
}

View File

@ -1,6 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import { DocumentType } from '~/document/enums';
import { DocumentService, OciService } from '~/document/services';
import { HelpSupportFaqResponseDto } from '../dtos/response';
import { LookupLanguage } from '../enums';
import helpSupportFaqs from '../data/help-support-faqs.json';
@Injectable()
export class LookupService {
@ -33,4 +36,16 @@ export class LookupService {
this.logger.log(`Default tasks logos returned successfully`);
return documents;
}
getHelpSupportFaqs(lang: LookupLanguage = LookupLanguage.EN): HelpSupportFaqResponseDto[] {
const useArabic = lang === LookupLanguage.AR;
return helpSupportFaqs.map((faq) =>
new HelpSupportFaqResponseDto({
id: faq.id,
question: useArabic ? faq.question_ar : faq.question_en,
answer: useArabic ? faq.answer_ar : faq.answer_en,
}),
);
}
}

View File

@ -6,10 +6,14 @@ export enum NotificationScope {
OTP = 'OTP',
USER_INVITED = 'USER_INVITED',
// Transaction notifications - Top-up
// Transaction notifications - Top-up (external funds)
CHILD_TOP_UP = 'CHILD_TOP_UP',
PARENT_TOP_UP_CONFIRMATION = 'PARENT_TOP_UP_CONFIRMATION',
// Transaction notifications - Internal Transfer (parent to child)
CHILD_INTERNAL_TRANSFER = 'CHILD_INTERNAL_TRANSFER',
PARENT_INTERNAL_TRANSFER = 'PARENT_INTERNAL_TRANSFER',
// Transaction notifications - Spending
CHILD_SPENDING = 'CHILD_SPENDING',
PARENT_SPENDING_ALERT = 'PARENT_SPENDING_ALERT',

View File

@ -139,14 +139,39 @@ export class MoneyRequestNotificationListener {
const currency = getCurrency(accountCurrency, null, 'SAR');
const formattedAmount = formatCurrencyAmount(amount, currency);
const locale = this.getUserLocale(parentUser);
this.logger.debug(
`Notifying parent (user ${parentUser.id}): ${childName} requested ${formattedAmount} ${currency} - ${reason}`
`Notifying parent (user ${parentUser.id}): ${childName} requested ${formattedAmount} ${currency} for ${reason}`
);
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.MONEY_REQUEST_CREATED_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.MONEY_REQUEST_CREATED_MESSAGE', {
lang: locale,
args: {
childName: childName,
amount: formattedAmount,
currency: currency,
reason: reason,
},
});
} catch (i18nError: any) {
this.logger.error(
`[MoneyRequestNotificationListener] i18n error for parent ${parentUser.id}: ${i18nError?.message || 'Unknown i18n error'}. Falling back to English.`,
i18nError?.stack
);
title = 'Money Request';
message = `${childName} has requested ${formattedAmount} ${currency} for ${reason}.`;
}
await this.notificationFactory.send({
userId: parentUser.id,
title: 'Money Request',
message: `${childName} requested ${formattedAmount} ${currency}. Reason: ${reason}`,
title,
message,
scope: NotificationScope.MONEY_REQUEST_CREATED,
preferences: this.getUserPreferences(parentUser),
data: {

View File

@ -97,36 +97,86 @@ export class TransactionNotificationListener {
return;
}
const scope = isTopUp
? NotificationScope.CHILD_TOP_UP
: NotificationScope.CHILD_SPENDING;
// Determine scope: internal transfer (parent to child) vs external top-up
let scope: NotificationScope;
if (isTopUp) {
scope = isChildSpending
? NotificationScope.CHILD_INTERNAL_TRANSFER // Parent transferring to child
: NotificationScope.CHILD_TOP_UP; // External top-up
} else {
scope = NotificationScope.CHILD_SPENDING;
}
const locale = this.getUserLocale(user);
const amount = transaction.transactionAmount;
const merchant = transaction.merchantName || 'merchant';
// For child top-up notifications, show card.limit (newAmount) instead of account balance
// card.limit represents the total spending limit on the card after the transfer
// For child notifications, show the appropriate balance based on account structure
let balance = 0;
let accountCurrency: string | undefined;
if (isTopUp) {
// For top-up: show card limit (newAmount from transfer response)
if (isTopUp && isChildSpending) {
// Internal transfer: For shared accounts, show card limit (child's spending power)
// For separate accounts, show child's account balance
try {
// Reload card to get updated limit
const cardWithUpdatedLimit = await this.cardService.getCardById(card.id);
balance = cardWithUpdatedLimit.limit || card.limit || 0;
accountCurrency = cardWithUpdatedLimit.account?.currency || card.account?.currency;
this.logger.debug(
`[Child Top-Up Notification] Using card limit (newAmount) - limit: ${balance} ${accountCurrency}`
);
// Reload card to get updated data
const cardWithUpdatedBalance = await this.cardService.getCardById(card.id);
// Check if child has parent (shared account scenario)
if (cardWithUpdatedBalance.parentId) {
// Likely shared account - use card limit as the child's "balance"
balance = cardWithUpdatedBalance.limit || card.limit || 0;
accountCurrency = cardWithUpdatedBalance.account?.currency || card.account?.currency;
this.logger.debug(
`[Child Internal Transfer] Shared account - using card limit: ${balance} ${accountCurrency}`
);
} else {
// Separate account - use child's account balance
if (cardWithUpdatedBalance?.account?.accountReference) {
const account = await this.accountService.getAccountByReferenceNumber(
cardWithUpdatedBalance.account.accountReference
);
balance = account.balance;
accountCurrency = account.currency;
this.logger.debug(
`[Child Internal Transfer] Separate account - using account balance: ${balance} ${accountCurrency}`
);
} else {
balance = cardWithUpdatedBalance.account?.balance || card.account?.balance || 0;
accountCurrency = cardWithUpdatedBalance.account?.currency || card.account?.currency;
}
}
} catch (error: any) {
this.logger.warn(
`[Child Top-Up Notification] Could not reload card: ${error?.message}. Using card limit from event.`
`[Child Internal Transfer] Could not fetch balance: ${error?.message}. Using card limit.`
);
balance = card.limit || 0;
accountCurrency = card.account?.currency;
}
} else if (isTopUp) {
// External top-up: show child's account balance
try {
const cardWithUpdatedBalance = await this.cardService.getCardById(card.id);
if (cardWithUpdatedBalance?.account?.accountReference) {
const account = await this.accountService.getAccountByReferenceNumber(
cardWithUpdatedBalance.account.accountReference
);
balance = account.balance;
accountCurrency = account.currency;
this.logger.debug(
`[Child Top-Up Notification] Fetched account by reference - balance: ${balance} ${accountCurrency}`
);
} else {
balance = cardWithUpdatedBalance.account?.balance || card.account?.balance || 0;
accountCurrency = cardWithUpdatedBalance.account?.currency || card.account?.currency;
}
} catch (error: any) {
this.logger.warn(
`[Child Top-Up Notification] Could not fetch account: ${error?.message}. Using card balance.`
);
balance = card.account?.balance || 0;
accountCurrency = card.account?.currency;
}
} else {
// For spending: show account balance
try {
@ -176,36 +226,44 @@ export class TransactionNotificationListener {
let message: string;
try {
title = isTopUp
? this.i18n.t('app.NOTIFICATION.CHILD_TOP_UP_TITLE', { lang: locale })
: this.i18n.t('app.NOTIFICATION.CHILD_SPENDING_TITLE', { lang: locale });
message = isTopUp
? this.i18n.t('app.NOTIFICATION.CHILD_TOP_UP_MESSAGE', {
lang: locale,
args: {
amount: formattedAmount,
currency: currency,
balance: formattedBalance,
},
})
: this.i18n.t('app.NOTIFICATION.CHILD_SPENDING_MESSAGE', {
lang: locale,
args: {
amount: formattedAmount,
currency: currency,
merchant: merchant,
balance: formattedBalance,
},
});
if (isTopUp) {
// Internal transfer or external top-up
const titleKey = isChildSpending
? 'app.NOTIFICATION.CHILD_INTERNAL_TRANSFER_TITLE'
: 'app.NOTIFICATION.CHILD_TOP_UP_TITLE';
const messageKey = isChildSpending
? 'app.NOTIFICATION.CHILD_INTERNAL_TRANSFER_MESSAGE'
: 'app.NOTIFICATION.CHILD_TOP_UP_MESSAGE';
title = this.i18n.t(titleKey, { lang: locale });
message = this.i18n.t(messageKey, {
lang: locale,
args: {
amount: formattedAmount,
currency: currency,
balance: formattedBalance,
},
});
} else {
// Spending
title = this.i18n.t('app.NOTIFICATION.CHILD_SPENDING_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.CHILD_SPENDING_MESSAGE', {
lang: locale,
args: {
amount: formattedAmount,
currency: currency,
merchant: merchant,
},
});
}
} catch (i18nError: any) {
console.error(`[TransactionNotificationListener] i18n error:`, i18nError);
this.logger.error(`i18n translation failed: ${i18nError?.message}`, i18nError?.stack);
// Fallback to English without i18n
title = isTopUp ? 'Card Topped Up' : 'Purchase Successful';
message = isTopUp
? `You received ${formattedAmount} ${currency}. Total balance: ${formattedBalance} ${currency}`
: `You spent ${formattedAmount} ${currency} at ${merchant}. Balance: ${formattedBalance} ${currency}`;
title = isTopUp ? 'Funds Credited' : 'Purchase Successful';
message = isTopUp
? `${formattedAmount} ${currency} has been added to your card. Total balance: ${formattedBalance} ${currency}`
: `You spent ${formattedAmount} ${currency} at ${merchant}`;
}
this.logger.debug(
@ -263,29 +321,80 @@ export class TransactionNotificationListener {
const amount = transaction.transactionAmount;
const merchant = transaction.merchantName || 'a merchant';
// Get parent's account balance (not child's balance) - reload to get fresh balance
// Get parent's available balance (balance - reserved_balance) - reload to get fresh balance
let parentAccountBalance = 0;
let parentAccountReservedBalance = 0;
let parentAccountCurrency: string | undefined;
let availableBalance = 0;
try {
if (card.parentId) {
// Always reload parent account to get fresh balance after transaction
const parentAccount = await this.accountService.getAccountByCustomerId(card.parentId);
parentAccountBalance = parentAccount.balance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(`Fetched parent account balance: ${parentAccountBalance}, currency: ${parentAccountCurrency}`);
// Get parent's card to access their account reference
const parentCard = await this.cardService.getCardByCustomerId(card.parentId);
if (parentCard?.account?.accountReference) {
// Fetch by reference number to get fresh balance from database
const parentAccount = await this.accountService.getAccountByReferenceNumber(
parentCard.account.accountReference
);
parentAccountBalance = parentAccount.balance;
parentAccountReservedBalance = parentAccount.reservedBalance;
availableBalance = parentAccountBalance - parentAccountReservedBalance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(
`[Parent Spending] Fetched parent account by reference - balance: ${parentAccountBalance}, reserved: ${parentAccountReservedBalance}, available: ${availableBalance} ${parentAccountCurrency}`
);
} else {
// Fallback: try by customer ID
const parentAccount = await this.accountService.getAccountByCustomerId(card.parentId);
parentAccountBalance = parentAccount.balance;
parentAccountReservedBalance = parentAccount.reservedBalance;
availableBalance = parentAccountBalance - parentAccountReservedBalance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(
`[Parent Spending] Fetched parent account by customer ID - balance: ${parentAccountBalance}, reserved: ${parentAccountReservedBalance}, available: ${availableBalance} ${parentAccountCurrency}`
);
}
} else {
const parentCustomer = customer?.junior?.guardian?.customer;
if (parentCustomer?.cards?.[0]?.account) {
// Reload to get fresh balance
const parentAccount = await this.accountService.getAccountByCustomerId(parentCustomer.id);
parentAccountBalance = parentAccount.balance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(`Fetched parent account balance via customer: ${parentAccountBalance}, currency: ${parentAccountCurrency}`);
if (parentCustomer?.id) {
try {
const parentCard = await this.cardService.getCardByCustomerId(parentCustomer.id);
if (parentCard?.account?.accountReference) {
const parentAccount = await this.accountService.getAccountByReferenceNumber(
parentCard.account.accountReference
);
parentAccountBalance = parentAccount.balance;
parentAccountReservedBalance = parentAccount.reservedBalance;
availableBalance = parentAccountBalance - parentAccountReservedBalance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(
`[Parent Spending] Fetched parent account via customer relation (by reference) - balance: ${parentAccountBalance}, reserved: ${parentAccountReservedBalance}, available: ${availableBalance} ${parentAccountCurrency}`
);
} else {
const parentAccount = await this.accountService.getAccountByCustomerId(parentCustomer.id);
parentAccountBalance = parentAccount.balance;
parentAccountReservedBalance = parentAccount.reservedBalance;
availableBalance = parentAccountBalance - parentAccountReservedBalance;
parentAccountCurrency = parentAccount.currency;
this.logger.debug(
`[Parent Spending] Fetched parent account via customer relation - balance: ${parentAccountBalance}, reserved: ${parentAccountReservedBalance}, available: ${availableBalance} ${parentAccountCurrency}`
);
}
} catch (error: any) {
this.logger.warn(
`[Parent Spending] Could not fetch parent account via customer: ${error?.message}. Using child account balance as fallback.`
);
availableBalance = card.account?.balance || 0;
parentAccountCurrency = card.account?.currency;
}
} else {
availableBalance = card.account?.balance || 0;
parentAccountCurrency = card.account?.currency;
}
}
} catch (error: any) {
this.logger.warn(`Could not fetch parent account for parent notification: ${error?.message}, using child account balance as fallback`);
parentAccountBalance = card.account?.balance || 0;
this.logger.warn(`[Parent Spending] Could not fetch parent account: ${error?.message}, using child account balance as fallback`);
availableBalance = card.account?.balance || 0;
parentAccountCurrency = card.account?.currency;
}
@ -297,11 +406,12 @@ export class TransactionNotificationListener {
);
this.logger.debug(
`[Parent Spending Notification] Parent account currency: ${parentAccountCurrency}, Account currency: ${accountCurrency}, Transaction currency: ${transaction.transactionCurrency}, Final currency: ${currency}, Parent balance: ${parentAccountBalance}, Amount: ${amount}`
`[Parent Spending Notification] Parent account currency: ${parentAccountCurrency}, Account currency: ${accountCurrency}, Transaction currency: ${transaction.transactionCurrency}, Final currency: ${currency}, Parent available balance: ${availableBalance}, Amount: ${amount}`
);
const formattedAmount = formatCurrencyAmount(amount, currency);
const formattedBalance = formatCurrencyAmount(parentAccountBalance, currency);
// Use available balance for parent spending notification
const formattedBalance = formatCurrencyAmount(availableBalance, currency);
this.logger.debug(
`Notifying parent (user ${parentUser.id}): ${childName} spent ${formattedAmount} ${currency} at ${merchant}`
@ -325,8 +435,8 @@ export class TransactionNotificationListener {
} catch (i18nError: any) {
console.error(`[TransactionNotificationListener] i18n error in parent spending:`, i18nError);
this.logger.error(`i18n translation failed: ${i18nError?.message}`, i18nError?.stack);
title = 'Child Spending Alert';
message = `${childName} spent ${formattedAmount} ${currency} at ${merchant}. Balance: ${formattedBalance} ${currency}`;
title = 'Spending Alert';
message = `${childName} spent ${formattedAmount} ${currency} at ${merchant}. Remaining balance: ${formattedBalance} ${currency}`;
}
await this.notificationFactory.send({
@ -360,7 +470,7 @@ export class TransactionNotificationListener {
}
/**
* Notify parent when they top up their child's card
* Notify parent when they transfer money to their child's card (internal transfer)
* This is a confirmation notification for the parent
*/
private async notifyParentOfTopUp(transaction: Transaction, card: Card): Promise<void> {
@ -479,15 +589,15 @@ export class TransactionNotificationListener {
const formattedBalance = formatCurrencyAmount(balance, currency);
this.logger.debug(
`Notifying parent (user ${parentUser.id}): Transferred ${formattedAmount} ${currency} to ${childName}, parent balance: ${formattedBalance} ${currency}`
`Notifying parent (user ${parentUser.id}): Transferred ${formattedAmount} ${currency} to ${childName}, child balance: ${formattedBalance} ${currency}`
);
let title: string;
let message: string;
try {
title = this.i18n.t('app.NOTIFICATION.PARENT_TOP_UP_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.PARENT_TOP_UP_MESSAGE', {
title = this.i18n.t('app.NOTIFICATION.PARENT_INTERNAL_TRANSFER_TITLE', { lang: locale });
message = this.i18n.t('app.NOTIFICATION.PARENT_INTERNAL_TRANSFER_MESSAGE', {
lang: locale,
args: {
amount: formattedAmount,
@ -497,17 +607,17 @@ export class TransactionNotificationListener {
},
});
} catch (i18nError: any) {
console.error(`[TransactionNotificationListener] i18n error in parent top-up:`, i18nError);
console.error(`[TransactionNotificationListener] i18n error in parent internal transfer:`, i18nError);
this.logger.error(`i18n translation failed: ${i18nError?.message}`, i18nError?.stack);
title = 'Top-Up Confirmation';
message = `You transferred ${formattedAmount} ${currency} to ${childName}. Balance: ${formattedBalance} ${currency}`;
title = 'Internal Transfer Completed';
message = `${formattedAmount} ${currency} has been transferred to ${childName}'s card. ${childName}'s balance is ${formattedBalance} ${currency}`;
}
await this.notificationFactory.send({
userId: parentUser.id,
title,
message,
scope: NotificationScope.PARENT_TOP_UP_CONFIRMATION,
scope: NotificationScope.PARENT_INTERNAL_TRANSFER,
preferences: this.getUserPreferences(parentUser),
data: {
transactionId: transaction.id,

View File

@ -112,17 +112,19 @@
"NOT_FOUND": "لم يتم العثور على البطاقة."
},
"NOTIFICATION": {
"CHILD_TOP_UP_TITLE": "تم شحن البطاقة",
"CHILD_TOP_UP_MESSAGE": "لقد استلمت {amount} {currency}. الرصيد الإجمالي: {balance} {currency}",
"CHILD_SPENDING_TITLE": "تمت العملية بنجاح",
"CHILD_SPENDING_MESSAGE": "لقد أنفقت {amount} {currency} في {merchant}. الرصيد: {balance} {currency}",
"PARENT_TOP_UP_TITLE": "تأكيد الشحن",
"PARENT_TOP_UP_MESSAGE": "لقد قمت بتحويل {amount} {currency} إلى {childName}. الرصيد: {balance} {currency}",
"PARENT_SPENDING_TITLE": "تنبيه إنفاق الطفل",
"PARENT_SPENDING_MESSAGE": "أنفق {childName} {amount} {currency} في {merchant}. الرصيد: {balance} {currency}",
"CHILD_TOP_UP_TITLE": "تم إضافة رصيد",
"CHILD_TOP_UP_MESSAGE": "تمت إضافة {amount} {currency} إلى بطاقتك. إجمالي الرصيد: {balance} {currency}",
"CHILD_INTERNAL_TRANSFER_TITLE": "تم إضافة رصيد",
"CHILD_INTERNAL_TRANSFER_MESSAGE": "تمت إضافة {amount} {currency} إلى بطاقتك. إجمالي الرصيد: {balance} {currency}",
"PARENT_INTERNAL_TRANSFER_TITLE": "اكتمل التحويل",
"PARENT_INTERNAL_TRANSFER_MESSAGE": "تم تحويل {amount} {currency} إلى بطاقة {childName}. رصيد {childName}: {balance} {currency}",
"CHILD_SPENDING_TITLE": "عملية شراء ناجحة",
"CHILD_SPENDING_MESSAGE": "قمت بإنفاق {amount} {currency} في {merchant}",
"PARENT_SPENDING_TITLE": "تنبيه صرف",
"PARENT_SPENDING_MESSAGE": "قام {childName} بإنفاق {amount} {currency} في {merchant}. الرصيد المتبقي: {balance} {currency}",
"YOUR_CHILD": "طفلك",
"MONEY_REQUEST_CREATED_TITLE": "طلب مال",
"MONEY_REQUEST_CREATED_MESSAGE": "طلب {childName} مبلغ {amount} {currency}. السبب: {reason}",
"MONEY_REQUEST_CREATED_TITLE": "طلب مبلغ مالي",
"MONEY_REQUEST_CREATED_MESSAGE": "طلب {childName} مبلغ {amount} {currency} لـ {reason}",
"MONEY_REQUEST_APPROVED_TITLE": "تمت الموافقة على طلب المال",
"MONEY_REQUEST_APPROVED_MESSAGE": "تمت الموافقة على طلبك بمبلغ {amount} {currency}. تمت إضافة المال إلى حسابك.",
"MONEY_REQUEST_DECLINED_TITLE": "تم رفض طلب المال",

View File

@ -111,17 +111,19 @@
"NOT_FOUND": "The card was not found."
},
"NOTIFICATION": {
"CHILD_TOP_UP_TITLE": "Card Topped Up",
"CHILD_TOP_UP_MESSAGE": "You received {amount} {currency}. Total balance: {balance} {currency}",
"CHILD_TOP_UP_TITLE": "Funds Credited",
"CHILD_TOP_UP_MESSAGE": "{amount} {currency} has been added to your card. Total balance: {balance} {currency}",
"CHILD_INTERNAL_TRANSFER_TITLE": "Funds Credited",
"CHILD_INTERNAL_TRANSFER_MESSAGE": "{amount} {currency} has been added to your card. Total balance: {balance} {currency}",
"PARENT_INTERNAL_TRANSFER_TITLE": "Internal Transfer Completed",
"PARENT_INTERNAL_TRANSFER_MESSAGE": "{amount} {currency} has been transferred to {childName}'s card. {childName}'s balance is {balance} {currency}",
"CHILD_SPENDING_TITLE": "Purchase Successful",
"CHILD_SPENDING_MESSAGE": "You spent {amount} {currency} at {merchant}. Balance: {balance} {currency}",
"PARENT_TOP_UP_TITLE": "Top-Up Confirmation",
"PARENT_TOP_UP_MESSAGE": "You transferred {amount} {currency} to {childName}. Balance: {balance} {currency}",
"PARENT_SPENDING_TITLE": "Child Spending Alert",
"PARENT_SPENDING_MESSAGE": "{childName} spent {amount} {currency} at {merchant}. Balance: {balance} {currency}",
"CHILD_SPENDING_MESSAGE": "You spent {amount} {currency} at {merchant}",
"PARENT_SPENDING_TITLE": "Spending Alert",
"PARENT_SPENDING_MESSAGE": "{childName} spent {amount} {currency} at {merchant}. Remaining balance: {balance} {currency}",
"YOUR_CHILD": "Your child",
"MONEY_REQUEST_CREATED_TITLE": "Money Request",
"MONEY_REQUEST_CREATED_MESSAGE": "{childName} requested {amount} {currency}. Reason: {reason}",
"MONEY_REQUEST_CREATED_MESSAGE": "{childName} has requested {amount} {currency} for {reason}.",
"MONEY_REQUEST_APPROVED_TITLE": "Money Request Approved",
"MONEY_REQUEST_APPROVED_MESSAGE": "Your request for {amount} {currency} has been approved. The money has been added to your account.",
"MONEY_REQUEST_DECLINED_TITLE": "Money Request Declined",