diff --git a/src/auth/dtos/request/junior-login.request.dto.ts b/src/auth/dtos/request/junior-login.request.dto.ts index 42e1e49..ce69834 100644 --- a/src/auth/dtos/request/junior-login.request.dto.ts +++ b/src/auth/dtos/request/junior-login.request.dto.ts @@ -23,4 +23,13 @@ export class JuniorLoginRequestDto { @IsOptional() @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) }) fcmToken?: string; + + @ApiProperty({ + example: 'Asia/Riyadh', + description: 'Device timezone (auto-detected from device OS)', + required: false, + }) + @IsOptional() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.timezone' }) }) + timezone?: string; } diff --git a/src/auth/dtos/request/login.request.dto.ts b/src/auth/dtos/request/login.request.dto.ts index 80dc810..709bc54 100644 --- a/src/auth/dtos/request/login.request.dto.ts +++ b/src/auth/dtos/request/login.request.dto.ts @@ -35,4 +35,13 @@ export class LoginRequestDto { @IsOptional() @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) }) fcmToken?: string; + + @ApiProperty({ + example: 'Asia/Riyadh', + description: 'Device timezone (auto-detected from device OS)', + required: false, + }) + @IsOptional() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.timezone' }) }) + timezone?: string; } diff --git a/src/auth/dtos/request/verify-user.request.dto.ts b/src/auth/dtos/request/verify-user.request.dto.ts index 373e1e2..62cfb8d 100644 --- a/src/auth/dtos/request/verify-user.request.dto.ts +++ b/src/auth/dtos/request/verify-user.request.dto.ts @@ -115,4 +115,13 @@ export class VerifyUserRequestDto { @IsOptional() @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) }) fcmToken?: string; + + @ApiProperty({ + example: 'Asia/Riyadh', + description: 'Device timezone (auto-detected from device OS)', + required: false, + }) + @IsOptional() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.timezone' }) }) + timezone?: string; } diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 341b372..88c53e5 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -87,9 +87,9 @@ export class AuthService { const tokens = await this.generateAuthToken(user); this.logger.log(`User with phone number ${user.fullPhoneNumber} verified successfully`); - // Register/update device with FCM token if provided + // Register/update device with FCM token and timezone if provided if (verifyUserDto.fcmToken && verifyUserDto.deviceId) { - await this.registerDeviceToken(user.id, verifyUserDto.deviceId, verifyUserDto.fcmToken); + await this.registerDeviceToken(user.id, verifyUserDto.deviceId, verifyUserDto.fcmToken, verifyUserDto.timezone); } return [tokens, user]; @@ -278,9 +278,9 @@ export class AuthService { const tokens = await this.generateAuthToken(user); this.logger.log(`Password validated successfully for user`); - // Register/update device with FCM token if provided + // Register/update device with FCM token and timezone if provided if (loginDto.fcmToken && loginDto.deviceId) { - await this.registerDeviceToken(user.id, loginDto.deviceId, loginDto.fcmToken); + await this.registerDeviceToken(user.id, loginDto.deviceId, loginDto.fcmToken, loginDto.timezone); } return [tokens, user]; @@ -304,22 +304,22 @@ export class AuthService { const tokens = await this.generateAuthToken(user); this.logger.log(`Password validated successfully for user`); - // Register/update device with FCM token if provided + // Register/update device with FCM token and timezone if provided if (juniorLoginDto.fcmToken && juniorLoginDto.deviceId) { - await this.registerDeviceToken(user.id, juniorLoginDto.deviceId, juniorLoginDto.fcmToken); + await this.registerDeviceToken(user.id, juniorLoginDto.deviceId, juniorLoginDto.fcmToken, juniorLoginDto.timezone); } return [tokens, user]; } /** - * Register or update device with FCM token + * Register or update device with FCM token and timezone * This method handles: - * 1. Device already exists for this user → Update FCM token + * 1. Device already exists for this user → Update FCM token and timezone * 2. Device exists for different user → Transfer device to new user * 3. Device doesn't exist → Create new device */ - private async registerDeviceToken(userId: string, deviceId: string, fcmToken: string): Promise { + private async registerDeviceToken(userId: string, deviceId: string, fcmToken: string, timezone?: string): Promise { try { this.logger.log(`Registering/updating device ${deviceId} with FCM token for user ${userId}`); @@ -327,13 +327,14 @@ export class AuthService { const existingDeviceForUser = await this.deviceService.findUserDeviceById(deviceId, userId); if (existingDeviceForUser) { - // Device exists for this user → Update FCM token and last access time + // Device exists for this user → Update FCM token, timezone, and last access time await this.deviceService.updateDevice(deviceId, { fcmToken, userId, + timezone, // Update timezone if provided lastAccessOn: new Date(), }); - this.logger.log(`Device ${deviceId} updated with new FCM token for user ${userId}`); + this.logger.log(`Device ${deviceId} updated with new FCM token and timezone for user ${userId}`); return; } @@ -348,6 +349,7 @@ export class AuthService { await this.deviceService.updateDevice(deviceId, { userId, fcmToken, + timezone, // Update timezone if provided lastAccessOn: new Date(), }); this.logger.log(`Device ${deviceId} transferred from user ${existingDevice.userId} to user ${userId}`); @@ -359,6 +361,7 @@ export class AuthService { deviceId, userId, fcmToken, + timezone, // Store timezone if provided lastAccessOn: new Date(), }); this.logger.log(`New device ${deviceId} registered with FCM token for user ${userId}`); diff --git a/src/common/modules/notification/dtos/response/notifications.response.dto.ts b/src/common/modules/notification/dtos/response/notifications.response.dto.ts index 8240da7..2c9e555 100644 --- a/src/common/modules/notification/dtos/response/notifications.response.dto.ts +++ b/src/common/modules/notification/dtos/response/notifications.response.dto.ts @@ -26,12 +26,13 @@ export class NotificationsResponseDto { // Use event timestamp from data if available, otherwise use notification creation time // This ensures notifications show when the event occurred, not when notification was saved - // Note: Timestamps are stored in UTC in the database, and should be converted to local timezone on the client side + // Note: Timestamps are stored in UTC. The client should convert to the user's local timezone. if (notification.data?.timestamp) { // Parse the ISO string timestamp (which is in UTC) - // The client should convert this to the user's local timezone + // The client should convert this to the user's local timezone based on their device settings this.createdAt = new Date(notification.data.timestamp); } else { + // Use notification creation time (also in UTC) this.createdAt = notification.createdAt; } } diff --git a/src/common/modules/notification/listeners/transaction-notification.listener.ts b/src/common/modules/notification/listeners/transaction-notification.listener.ts index 8537d76..c2e847a 100644 --- a/src/common/modules/notification/listeners/transaction-notification.listener.ts +++ b/src/common/modules/notification/listeners/transaction-notification.listener.ts @@ -109,11 +109,17 @@ export class TransactionNotificationListener { const cardWithUpdatedBalance = await this.cardService.getCardById(card.id); const balance = cardWithUpdatedBalance.account?.balance || card.account?.balance || 0; + const accountCurrency = cardWithUpdatedBalance.account?.currency || card.account?.currency; const currency = getCurrency( - cardWithUpdatedBalance.account?.currency || card.account?.currency, + accountCurrency, transaction.transactionCurrency, 'SAR' ); + + this.logger.debug( + `[Child Notification] Account currency: ${accountCurrency}, Transaction currency: ${transaction.transactionCurrency}, Final currency: ${currency}, Balance: ${balance}, Amount: ${amount}` + ); + const formattedAmount = formatCurrencyAmount(amount, currency); const formattedBalance = formatCurrencyAmount(balance, currency); @@ -166,14 +172,14 @@ export class TransactionNotificationListener { data: { transactionId: transaction.id, amount: formattedAmount, - currency: currency, + currency: currency, // ISO currency code (SAR, USD, etc.) merchant: merchant, merchantCategory: transaction.merchantCategoryCode || 'OTHER', balance: formattedBalance, - timestamp: transaction.transactionDate.toISOString(), - type: isTopUp ? 'TOP_UP' : 'SPENDING', - action: 'OPEN_TRANSACTION', - }, + timestamp: transaction.transactionDate.toISOString(), + type: isTopUp ? 'TOP_UP' : 'SPENDING', + action: 'OPEN_TRANSACTION', + }, }); this.logger.log(`✅ Notified user ${user.id} for transaction ${transaction.id}`); @@ -208,30 +214,43 @@ export class TransactionNotificationListener { const amount = transaction.transactionAmount; const merchant = transaction.merchantName || 'a merchant'; - // Get parent's account balance (not child's balance) + // Get parent's account balance (not child's balance) - reload to get fresh balance let parentAccountBalance = 0; let parentAccountCurrency: string | undefined; try { - const parentCustomer = customer?.junior?.guardian?.customer; - if (parentCustomer?.cards?.[0]?.account) { - parentAccountBalance = parentCustomer.cards[0].account.balance; - parentAccountCurrency = parentCustomer.cards[0].account.currency; - } else if (card.parentId) { + 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}`); + } 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}`); + } } - } catch (error) { - this.logger.warn(`Could not fetch parent account for parent notification, using child account balance as fallback`); + } 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; parentAccountCurrency = card.account?.currency; } + const accountCurrency = parentAccountCurrency || card.account?.currency; const currency = getCurrency( - parentAccountCurrency || card.account?.currency, + accountCurrency, transaction.transactionCurrency, 'SAR' ); + + 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}` + ); + const formattedAmount = formatCurrencyAmount(amount, currency); const formattedBalance = formatCurrencyAmount(parentAccountBalance, currency); @@ -271,8 +290,8 @@ export class TransactionNotificationListener { transactionId: transaction.id, childId: childUser.id, childName: childName, - amount: amount.toString(), - currency: currency, + amount: formattedAmount, // Use formatted amount instead of raw amount + currency: currency, // ISO currency code (SAR, USD, etc.) merchant: merchant, merchantCategory: transaction.merchantCategoryCode || 'OTHER', balance: formattedBalance, @@ -313,30 +332,41 @@ export class TransactionNotificationListener { const childName = childUser?.firstName || defaultChildName; const amount = transaction.transactionAmount; - const parentCustomer = customer?.junior?.guardian?.customer; - let parentAccount = parentCustomer?.cards?.[0]?.account; - - // Reload parent account to get updated balance after transfer - if (!parentAccount && card.parentId) { + // Always reload parent account to get updated balance after transfer + let parentAccount: any = null; + if (card.parentId) { try { parentAccount = await this.accountService.getAccountByCustomerId(card.parentId); - } catch (error) { - this.logger.warn(`Could not fetch parent account for customer ${card.parentId}, using child account balance`); + this.logger.debug(`Fetched parent account for top-up notification - balance: ${parentAccount.balance}, currency: ${parentAccount.currency}`); + } catch (error: any) { + this.logger.warn(`Could not fetch parent account for customer ${card.parentId}: ${error?.message}, using child account balance`); } - } else if (parentAccount && card.parentId) { - // Reload to get fresh balance - try { - parentAccount = await this.accountService.getAccountByCustomerId(card.parentId); - } catch (error) { - this.logger.warn(`Could not reload parent account, using cached balance`); + } + + // If parent account not found, try via customer relation + if (!parentAccount) { + const parentCustomer = customer?.junior?.guardian?.customer; + if (parentCustomer?.id) { + try { + parentAccount = await this.accountService.getAccountByCustomerId(parentCustomer.id); + this.logger.debug(`Fetched parent account via customer relation - balance: ${parentAccount.balance}, currency: ${parentAccount.currency}`); + } catch (error: any) { + this.logger.warn(`Could not fetch parent account via customer: ${error?.message}`); + } } } const balance = parentAccount?.balance || card.account?.balance || 0; + const accountCurrency = parentAccount?.currency || card.account?.currency; const currency = getCurrency( - parentAccount?.currency || card.account?.currency, + accountCurrency, transaction.transactionCurrency, ); + + this.logger.debug( + `[Parent Top-Up Notification] Parent account currency: ${parentAccount?.currency}, Account currency: ${accountCurrency}, Transaction currency: ${transaction.transactionCurrency}, Final currency: ${currency}, Parent balance: ${balance}, Amount: ${amount}` + ); + const formattedAmount = formatCurrencyAmount(amount, currency); const formattedBalance = formatCurrencyAmount(balance, currency); @@ -376,7 +406,7 @@ export class TransactionNotificationListener { childId: childUser.id, childName: childName, amount: formattedAmount, - currency: currency, + currency: currency, // ISO currency code (SAR, USD, etc.) balance: formattedBalance, timestamp: transaction.transactionDate.toISOString(), type: 'TOP_UP', diff --git a/src/common/redis/redis.module.ts b/src/common/redis/redis.module.ts index 5c95bed..30b106e 100644 --- a/src/common/redis/redis.module.ts +++ b/src/common/redis/redis.module.ts @@ -14,7 +14,15 @@ export class RedisModule { { provide: 'REDIS_PUBLISHER', useFactory: async (configService: ConfigService) => { - const publisher = createClient({ url: configService.get('REDIS_URL') }); + // Skip Redis connection during migration generation + if (process.env.MIGRATIONS_RUN === 'false') { + return null; + } + const redisUrl = configService.get('REDIS_URL'); + if (!redisUrl) { + return null; + } + const publisher = createClient({ url: redisUrl }); await publisher.connect(); return publisher; }, @@ -24,7 +32,15 @@ export class RedisModule { { provide: 'REDIS_SUBSCRIBER', useFactory: async (configService: ConfigService) => { - const subscriber = createClient({ url: configService.get('REDIS_URL') }); + // Skip Redis connection during migration generation + if (process.env.MIGRATIONS_RUN === 'false') { + return null; + } + const redisUrl = configService.get('REDIS_URL'); + if (!redisUrl) { + return null; + } + const subscriber = createClient({ url: redisUrl }); await subscriber.connect(); return subscriber; }, diff --git a/src/common/redis/services/redis-pubsub.service.ts b/src/common/redis/services/redis-pubsub.service.ts index 8ad8910..30ea62c 100644 --- a/src/common/redis/services/redis-pubsub.service.ts +++ b/src/common/redis/services/redis-pubsub.service.ts @@ -15,6 +15,10 @@ export class RedisPubSubService implements OnModuleInit { ) {} onModuleInit() { + // Skip subscription during migration generation + if (process.env.MIGRATIONS_RUN === 'false' || !this.subscriber) { + return; + } this.subscriber.subscribe(EventType.NOTIFICATION_CREATED, async (message) => { const data = JSON.parse(message); this.logger.log('Received message on NOTIFICATION_CREATED channel:', data); diff --git a/src/common/utils/currency.util.ts b/src/common/utils/currency.util.ts index 096a176..f737d4c 100644 --- a/src/common/utils/currency.util.ts +++ b/src/common/utils/currency.util.ts @@ -80,7 +80,7 @@ export function formatCurrencyAmount(amount: number | string, currency: string): /** * Get currency from account or transaction, with fallback - * @param accountCurrency - Currency from account entity + * @param accountCurrency - Currency from account entity (may be numeric like '682') * @param transactionCurrency - Currency from transaction entity (may be numeric) * @param fallback - Fallback currency (default: 'SAR') * @returns ISO currency code @@ -90,9 +90,16 @@ export function getCurrency( transactionCurrency?: string | null, fallback: string = 'SAR' ): string { - // Prefer account currency (already in ISO format) + // Convert account currency first (it may be numeric like '682') if (accountCurrency) { - return accountCurrency; + const converted = numericToCurrencyCode(accountCurrency); + if (converted && converted !== accountCurrency) { + return converted; // Successfully converted from numeric to ISO + } + // If already ISO format, return as is + if (/^[A-Z]{3}$/.test(accountCurrency)) { + return accountCurrency; + } } // Convert transaction currency (may be numeric) diff --git a/src/db/migrations/1768395622276-AddTimezoneFields.ts b/src/db/migrations/1768395622276-AddTimezoneFields.ts new file mode 100644 index 0000000..c50cf49 --- /dev/null +++ b/src/db/migrations/1768395622276-AddTimezoneFields.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddTimezoneFields1768395622276 implements MigrationInterface { + name = 'AddTimezoneFields1768395622276' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "timezone" character varying(50)`); + await queryRunner.query(`ALTER TABLE "devices" ADD "timezone" character varying(50)`); + await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "is_push_enabled" SET DEFAULT true`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "is_push_enabled" SET DEFAULT false`); + await queryRunner.query(`ALTER TABLE "devices" DROP COLUMN "timezone"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "timezone"`); + } + +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index c14e35b..32470e2 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -11,3 +11,4 @@ export * from './1765804942393-AddKycFieldsAndTransactions'; export * from './1765877128065-AddNationalIdToKycTransactions'; export * from './1765891028260-RemoveOldCustomerColumns'; export * from './1765975126402-RemoveAddressColumns'; +export * from './1768395622276-AddTimezoneFields'; \ No newline at end of file diff --git a/src/user/dtos/request/update-user.request.dto.ts b/src/user/dtos/request/update-user.request.dto.ts index dc04295..663b393 100644 --- a/src/user/dtos/request/update-user.request.dto.ts +++ b/src/user/dtos/request/update-user.request.dto.ts @@ -34,4 +34,12 @@ export class UpdateUserRequestDto { @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) @IsOptional() dateOfBirth!: Date; + + @ApiPropertyOptional({ + example: 'Asia/Riyadh', + description: 'User preferred timezone for reports/statements (e.g., "Asia/Riyadh", "America/New_York"). Leave empty or "Auto" to use device timezone.', + }) + @IsOptional() + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'user.timezone' }) }) + timezone?: string; } diff --git a/src/user/entities/device.entity.ts b/src/user/entities/device.entity.ts index 111a398..9eb7694 100644 --- a/src/user/entities/device.entity.ts +++ b/src/user/entities/device.entity.ts @@ -18,6 +18,9 @@ export class Device { @Column('varchar', { name: 'fcm_token', nullable: true }) fcmToken?: string | null; + @Column('varchar', { name: 'timezone', nullable: true, length: 50 }) + timezone?: string | null; // e.g., "Asia/Riyadh", "America/New_York" - auto-detected from device + @Column('timestamp with time zone', { name: 'last_access_on', default: () => 'CURRENT_TIMESTAMP' }) lastAccessOn!: Date; diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index 72cc9b4..caa91a1 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -67,6 +67,9 @@ export class User extends BaseEntity { @Column({ name: 'is_sms_enabled', default: false }) isSmsEnabled!: boolean; + @Column('varchar', { name: 'timezone', nullable: true, length: 50 }) + timezone?: string | null; // User's preferred timezone for reports/statements (e.g., "Asia/Riyadh") + @Column('text', { nullable: true, array: true, name: 'roles' }) roles!: Roles[];