Compare commits

...

21 Commits

Author SHA1 Message Date
2a62787c3b Merge pull request #63 from HamzaSha1/feature/kyc-onboarding-metadata
refactor: remove unused PoiValidationRule class from KycMetadataRespo
2025-11-18 15:16:33 +03:00
91dea22f45 refactor: remove unused PoiValidationRule class from KycMetadataResponseDto 2025-11-18 15:14:47 +03:00
ef28c75f9b Merge pull request #62 from HamzaSha1/feature/kyc-onboarding-metadata
feat: add KYC onboarding metadata endpoint with POI validation
2025-11-18 15:06:50 +03:00
c007ac584f feat: add KYC onboarding metadata endpoint with POI validation 2025-11-18 15:03:42 +03:00
d2d83549b2 Merge pull request #61 from HamzaSha1/fix/junior-profile-picture-refresh-on-update
Enhance profile picture handling in JuniorService to ensure foreign
2025-11-09 12:43:54 +03:00
506974afc8 Enhance profile picture handling in JuniorService to ensure foreign key consistency and validate document ownership before assignment. 2025-11-09 12:42:48 +03:00
95f8cfbfdf Merge pull request #60 from HamzaSha1/fix/junior-profile-picture-refresh-on-update
Update return value in updateJunior method to fetch updated junior dtails by ID instead of returning the junior object directly.
2025-11-09 12:26:44 +03:00
8b00cda23d Update return value in updateJunior method to fetch updated junior details by ID instead of returning the junior object directly. 2025-11-09 12:25:37 +03:00
12cc88a50e Merge pull request #59 from HamzaSha1/money-request-to-use-the-parint-account
Refactor balance check in increaseReservedBalance method to delegate …
2025-11-02 12:41:51 +03:00
2172051093 Refactor balance check in increaseReservedBalance method to delegate validation to the caller, improving clarity and responsibility separation. 2025-11-02 12:41:16 +03:00
a6a573957c Merge pull request #58 from HamzaSha1/money-request-to-use-the-parint-account
add more loggs
2025-11-02 12:35:31 +03:00
d6fb5f48d9 add more loggs 2025-11-02 12:34:41 +03:00
b0011eb7cc Merge pull request #57 from HamzaSha1/money-request-to-use-the-parint-account
Money request to use the parint account
2025-11-02 12:07:13 +03:00
99af65a300 money-request to use the parent card 2025-11-02 11:57:41 +03:00
0c9b40132a Merge pull request #56 from HamzaSha1/ZOD-344-after-a-child-completes-registration-using-the-qr-code-the-same-qr-code-remains-valid-and-allows-the-child-to-register-again-instead-of-expiring
ZOD-344-Add QR code validation error handling and localization support
2025-11-02 11:02:25 +03:00
3b295ea79f ZOD-344-Add QR code validation error handling and localization support
- Introduced new error handling for already used or expired QR codes in JuniorService.
- Added corresponding localization entries in Arabic and English app.json files for QR code validation messages.
2025-11-02 10:52:43 +03:00
5ffe18ede3 Merge pull request #54 from HamzaSha1/fix/verfy-email
Implement OTP generation and email verification logic in UserService
2025-10-28 16:17:51 +03:00
a3a61b4923 Implement OTP generation and email verification logic in UserService 2025-10-28 15:52:24 +03:00
39d5fc1869 Merge pull request #52 from HamzaSha1/ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view
Enhance weekly summary functionality to accept optional date range pa…
2025-10-28 11:22:52 +03:00
05a6ad2d84 Enhance weekly summary functionality to accept optional date range parameters in CardService, TransactionService, JuniorService, and JuniorController. Update API documentation to reflect new query parameters for start and end dates. 2025-10-28 11:20:49 +03:00
5649d24724 Merge pull request #50 from HamzaSha1/ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view
git checkout -b ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view
2025-10-26 16:05:00 +03:00
22 changed files with 404 additions and 25 deletions

View File

@ -64,9 +64,8 @@ export class AccountService {
} }
increaseReservedBalance(account: Account, amount: number) { increaseReservedBalance(account: Account, amount: number) {
if (account.balance < account.reservedBalance + amount) { // Balance check is performed by the caller (e.g., transferToChild)
throw new UnprocessableEntityException('CARD.INSUFFICIENT_BALANCE'); // to ensure correct account (guardian vs child) is validated
}
return this.accountRepository.increaseReservedBalance(account.id, amount); return this.accountRepository.increaseReservedBalance(account.id, amount);
} }

View File

@ -148,7 +148,18 @@ export class CardService {
async transferToChild(juniorId: string, amount: number) { async transferToChild(juniorId: string, amount: number) {
const card = await this.getCardByCustomerId(juniorId); const card = await this.getCardByCustomerId(juniorId);
if (amount > card.account.balance - card.account.reservedBalance) { 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'); throw new BadRequestException('CARD.INSUFFICIENT_BALANCE');
} }
@ -156,15 +167,15 @@ export class CardService {
await Promise.all([ await Promise.all([
this.neoleapService.updateCardControl(card.cardReference, finalAmount.toNumber()), this.neoleapService.updateCardControl(card.cardReference, finalAmount.toNumber()),
this.updateCardLimit(card.id, finalAmount.toNumber()), this.updateCardLimit(card.id, finalAmount.toNumber()),
this.accountService.increaseReservedBalance(card.account, amount), this.accountService.increaseReservedBalance(fundingAccount, amount),
this.transactionService.createInternalChildTransaction(card.id, amount), this.transactionService.createInternalChildTransaction(card.id, amount),
]); ]);
return finalAmount.toNumber(); return finalAmount.toNumber();
} }
getWeeklySummary(juniorId: string) { getWeeklySummary(juniorId: string, startDate?: Date, endDate?: Date) {
return this.transactionService.getWeeklySummary(juniorId); return this.transactionService.getWeeklySummary(juniorId, startDate, endDate);
} }
fundIban(iban: string, amount: number) { fundIban(iban: string, amount: number) {

View File

@ -42,10 +42,18 @@ export class TransactionService {
const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees); const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees);
if (card.customerType === CustomerType.CHILD) { 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([ await Promise.all([
this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()), this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()),
this.accountService.decrementReservedBalance(card.account, total.toNumber()), this.accountService.decrementReservedBalance(card.account, total.toNumber()),
]); ]);
}
} else { } else {
await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()); await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber());
} }
@ -84,15 +92,28 @@ export class TransactionService {
return existingTransaction; return existingTransaction;
} }
async getWeeklySummary(juniorId: string) { async getWeeklySummary(juniorId: string, startDate?: Date, endDate?: Date) {
const endOfWeek = moment().toDate(); let startOfWeek: Date;
const startOfWeek = moment().subtract(1, 'week').toDate(); 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( const transactions = await this.transactionRepository.getTransactionsForCardWithinDateRange(
juniorId, juniorId,
startOfWeek, startOfWeek,
endOfWeek, endOfWeek,
); );
const summary = { const summary = {
startOfWeek: startOfWeek, startOfWeek: startOfWeek,
endOfWeek: endOfWeek, endOfWeek: endOfWeek,

View File

@ -1,12 +1,12 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { IJwtPayload } from '~/auth/interfaces'; import { IJwtPayload } from '~/auth/interfaces';
import { AuthenticatedUser } from '~/common/decorators'; import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards'; import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils'; import { ResponseFactory } from '~/core/utils';
import { InitiateKycRequestDto } from '../dtos/request'; import { InitiateKycRequestDto } from '../dtos/request';
import { CustomerResponseDto, InitiateKycResponseDto } from '../dtos/response'; import { CustomerResponseDto, InitiateKycResponseDto, KycMetadataResponseDto } from '../dtos/response';
import { CustomerService } from '../services'; import { CustomerService } from '../services';
@Controller('customers') @Controller('customers')
@ -32,4 +32,14 @@ export class CustomerController {
return ResponseFactory.data(new InitiateKycResponseDto(res.randomNumber)); return ResponseFactory.data(new InitiateKycResponseDto(res.randomNumber));
} }
@Get('/kyc/onboard-metadata')
@UseGuards(AccessTokenGuard)
@ApiOperation({ summary: 'Get KYC onboarding form metadata' })
@ApiDataResponse(KycMetadataResponseDto)
async getKycMetadata() {
const metadata = await this.customerService.getKycOnboardMetadata();
return ResponseFactory.data(metadata);
}
} }

View File

@ -6,12 +6,12 @@ import { UserModule } from '~/user/user.module';
import { CustomerController } from './controllers'; import { CustomerController } from './controllers';
import { Customer } from './entities'; import { Customer } from './entities';
import { CustomerRepository } from './repositories/customer.repository'; import { CustomerRepository } from './repositories/customer.repository';
import { CustomerService } from './services'; import { CustomerService, MetadataService } from './services';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Customer]), GuardianModule, forwardRef(() => UserModule), NeoLeapModule], imports: [TypeOrmModule.forFeature([Customer]), GuardianModule, forwardRef(() => UserModule), NeoLeapModule],
controllers: [CustomerController], controllers: [CustomerController],
providers: [CustomerService, CustomerRepository], providers: [CustomerService, CustomerRepository, MetadataService],
exports: [CustomerService], exports: [CustomerService],
}) })
export class CustomerModule {} export class CustomerModule {}

View File

@ -1,2 +1,3 @@
export * from './customer.response.dto'; export * from './customer.response.dto';
export * from './initiate-kyc.response.dto'; export * from './initiate-kyc.response.dto';
export * from './kyc-metadata.response.dto';

View File

@ -0,0 +1,12 @@
export class MetadataOptionDto {
value!: string;
label!: string;
}
export class KycMetadataResponseDto {
poiTypes!: MetadataOptionDto[];
jobSectors!: MetadataOptionDto[];
incomeSources!: MetadataOptionDto[];
jobCategories!: MetadataOptionDto[];
incomeRanges!: MetadataOptionDto[];
}

View File

@ -0,0 +1,8 @@
export enum IncomeRange {
BELOW_2000 = 'SAR 2,000 and below',
RANGE_2000_5000 = 'SAR 2,000 to 5,000',
RANGE_5000_10000 = 'SAR 5,000 to 10,000',
RANGE_10000_20000 = 'SAR 10,000 to 20,000',
ABOVE_20000 = 'SAR 20,000 and above',
}

View File

@ -0,0 +1,9 @@
export enum IncomeSource {
SALARY = 'SALARY',
ANCESTRAL = 'ANCESTRAL',
REAL_ESTATE = 'REAL_ESTATE',
INVESTMENT_RETURNS = 'INVESTMENT_RETURNS',
RENTAL_INCOME = 'RENTAL_INCOME',
OTHER = 'OTHER',
}

View File

@ -1,3 +1,8 @@
export * from './customer-status.enum'; export * from './customer-status.enum';
export * from './gender.enum'; export * from './gender.enum';
export * from './kyc-status.enum'; export * from './kyc-status.enum';
export * from './poi-type.enum';
export * from './job-sector.enum';
export * from './income-source.enum';
export * from './job-category.enum';
export * from './income-range.enum';

View File

@ -0,0 +1,57 @@
export enum JobCategory {
ASSISTANT_MINISTER = 'ASSISTANT_MINISTER',
DEPUTY_MINISTER = 'DEPUTY_MINISTER',
UNDER_SECRETARY = 'UNDER_SECRETARY',
GENERAL_MANAGER = 'GENERAL_MANAGER',
CHAIRMAN = 'CHAIRMAN',
MANAGER = 'MANAGER',
PROFESSOR = 'PROFESSOR',
HEAD_OF_COURT = 'HEAD_OF_COURT',
JUDGE = 'JUDGE',
LAWYER = 'LAWYER',
SCIENTIST = 'SCIENTIST',
NOTARY = 'NOTARY',
BUSINESSMAN = 'BUSINESSMAN',
MERCHANT = 'MERCHANT',
PHARMACIST = 'PHARMACIST',
DOCTOR = 'DOCTOR',
MEDICAL_TECHNICIAN = 'MEDICAL_TECHNICIAN',
NURSE = 'NURSE',
ENGINEER = 'ENGINEER',
CHEMIST = 'CHEMIST',
CONTRACTOR = 'CONTRACTOR',
AUDITOR_ACCOUNTANT = 'AUDITOR_ACCOUNTANT',
RESEARCHER = 'RESEARCHER',
ACCOUNTANT = 'ACCOUNTANT',
JOURNALIST = 'JOURNALIST',
DESIGNER = 'DESIGNER',
COMPUTER_SPECIALIST = 'COMPUTER_SPECIALIST',
TRANSLATOR = 'TRANSLATOR',
TEACHER = 'TEACHER',
PILOT = 'PILOT',
HOST = 'HOST',
OFFICER = 'OFFICER',
SOLDIER = 'SOLDIER',
RETIRED = 'RETIRED',
SALESMAN = 'SALESMAN',
AUTHOR = 'AUTHOR',
CRAFTSMAN = 'CRAFTSMAN',
SECURITY = 'SECURITY',
LABORER = 'LABORER',
DRIVER = 'DRIVER',
FARMER = 'FARMER',
HOUSEWIFE = 'HOUSEWIFE',
DIPLOMAT = 'DIPLOMAT',
STUDENT = 'STUDENT',
FREELANCER = 'FREELANCER',
SHEPHERD = 'SHEPHERD',
HOUSEMAID_OR_BABYSITTER = 'HOUSEMAID_OR_BABYSITTER',
CAPTAIN = 'CAPTAIN',
AMBASSADOR = 'AMBASSADOR',
MARKETING = 'MARKETING',
CONSULTING = 'CONSULTING',
SUPERVISOR = 'SUPERVISOR',
BANKER = 'BANKER',
BODYGUARD_OR_PERSONAL_ASSISTANT = 'BODYGUARD_OR_PERSONAL_ASSISTANT',
}

View File

@ -0,0 +1,12 @@
export enum JobSector {
GOVERNMENT_SECTOR = 'GOVERNMENT_SECTOR',
HOME_MAKER = 'HOME_MAKER',
MILITARY = 'MILITARY',
PRIVATE_SECTOR = 'PRIVATE_SECTOR',
RETIRED = 'RETIRED',
SELF_EMPLOYED = 'SELF_EMPLOYED',
STUDENT = 'STUDENT',
HOUSEHOLD_LABOR = 'HOUSEHOLD_LABOR',
UNEMPLOYED = 'UNEMPLOYED',
}

View File

@ -0,0 +1,5 @@
export enum PoiType {
IQA = 'IQA', // Iqama (Resident ID)
NAT = 'NAT', // National ID
}

View File

@ -12,6 +12,7 @@ import { InitiateKycRequestDto } from '../dtos/request';
import { Customer } from '../entities'; import { Customer } from '../entities';
import { Gender, KycStatus } from '../enums'; import { Gender, KycStatus } from '../enums';
import { CustomerRepository } from '../repositories/customer.repository'; import { CustomerRepository } from '../repositories/customer.repository';
import { MetadataService } from './metadata.service';
@Injectable() @Injectable()
export class CustomerService { export class CustomerService {
@ -20,6 +21,7 @@ export class CustomerService {
private readonly customerRepository: CustomerRepository, private readonly customerRepository: CustomerRepository,
private readonly guardianService: GuardianService, private readonly guardianService: GuardianService,
@Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService, @Inject(forwardRef(() => NeoLeapService)) private readonly neoleapService: NeoLeapService,
private readonly metadataService: MetadataService,
) {} ) {}
async updateCustomer(userId: string, data: Partial<Customer>): Promise<Customer> { async updateCustomer(userId: string, data: Partial<Customer>): Promise<Customer> {
@ -149,6 +151,11 @@ export class CustomerService {
return this.findCustomerById(userId); return this.findCustomerById(userId);
} }
getKycOnboardMetadata() {
this.logger.log('Getting KYC onboard metadata');
return this.metadataService.getKycOnboardMetadata();
}
// TO BE REMOVED: This function is for testing only and will be removed // TO BE REMOVED: This function is for testing only and will be removed
private generateSaudiPhoneNumber(): string { private generateSaudiPhoneNumber(): string {
// Saudi mobile numbers are 9 digits, always starting with '5' // Saudi mobile numbers are 9 digits, always starting with '5'

View File

@ -1 +1,2 @@
export * from './customer.service'; export * from './customer.service';
export * from './metadata.service';

View File

@ -0,0 +1,105 @@
import { Injectable } from '@nestjs/common';
import { IncomeRange, IncomeSource, JobCategory, JobSector, PoiType } from '../enums';
import { KycMetadataResponseDto, MetadataOptionDto } from '../dtos/response';
@Injectable()
export class MetadataService {
getKycOnboardMetadata(): KycMetadataResponseDto {
return {
poiTypes: this.enumToOptions(PoiType, {
[PoiType.IQA]: 'Iqama (Resident ID)',
[PoiType.NAT]: 'National ID',
}),
jobSectors: this.enumToOptions(JobSector, {
[JobSector.GOVERNMENT_SECTOR]: 'Government Sector',
[JobSector.HOME_MAKER]: 'Home Maker',
[JobSector.MILITARY]: 'Military',
[JobSector.PRIVATE_SECTOR]: 'Private Sector',
[JobSector.RETIRED]: 'Retired',
[JobSector.SELF_EMPLOYED]: 'Self Employed',
[JobSector.STUDENT]: 'Student',
[JobSector.HOUSEHOLD_LABOR]: 'Household Labor',
[JobSector.UNEMPLOYED]: 'Unemployed',
}),
incomeSources: this.enumToOptions(IncomeSource, {
[IncomeSource.SALARY]: 'Salary',
[IncomeSource.ANCESTRAL]: 'Ancestral/Inheritance',
[IncomeSource.REAL_ESTATE]: 'Real Estate',
[IncomeSource.INVESTMENT_RETURNS]: 'Investment Returns',
[IncomeSource.RENTAL_INCOME]: 'Rental Income',
[IncomeSource.OTHER]: 'Other',
}),
jobCategories: this.enumToOptions(JobCategory, {
[JobCategory.ASSISTANT_MINISTER]: 'Assistant Minister',
[JobCategory.DEPUTY_MINISTER]: 'Deputy Minister',
[JobCategory.UNDER_SECRETARY]: 'Under Secretary',
[JobCategory.GENERAL_MANAGER]: 'General Manager',
[JobCategory.CHAIRMAN]: 'Chairman',
[JobCategory.MANAGER]: 'Manager',
[JobCategory.PROFESSOR]: 'Professor',
[JobCategory.HEAD_OF_COURT]: 'Head of Court',
[JobCategory.JUDGE]: 'Judge',
[JobCategory.LAWYER]: 'Lawyer',
[JobCategory.SCIENTIST]: 'Scientist',
[JobCategory.NOTARY]: 'Notary',
[JobCategory.BUSINESSMAN]: 'Businessman',
[JobCategory.MERCHANT]: 'Merchant',
[JobCategory.PHARMACIST]: 'Pharmacist',
[JobCategory.DOCTOR]: 'Doctor',
[JobCategory.MEDICAL_TECHNICIAN]: 'Medical Technician',
[JobCategory.NURSE]: 'Nurse',
[JobCategory.ENGINEER]: 'Engineer',
[JobCategory.CHEMIST]: 'Chemist',
[JobCategory.CONTRACTOR]: 'Contractor',
[JobCategory.AUDITOR_ACCOUNTANT]: 'Auditor/Accountant',
[JobCategory.RESEARCHER]: 'Researcher',
[JobCategory.ACCOUNTANT]: 'Accountant',
[JobCategory.JOURNALIST]: 'Journalist',
[JobCategory.DESIGNER]: 'Designer',
[JobCategory.COMPUTER_SPECIALIST]: 'Computer Specialist',
[JobCategory.TRANSLATOR]: 'Translator',
[JobCategory.TEACHER]: 'Teacher',
[JobCategory.PILOT]: 'Pilot',
[JobCategory.HOST]: 'Host',
[JobCategory.OFFICER]: 'Officer',
[JobCategory.SOLDIER]: 'Soldier',
[JobCategory.RETIRED]: 'Retired',
[JobCategory.SALESMAN]: 'Salesman',
[JobCategory.AUTHOR]: 'Author',
[JobCategory.CRAFTSMAN]: 'Craftsman',
[JobCategory.SECURITY]: 'Security',
[JobCategory.LABORER]: 'Laborer',
[JobCategory.DRIVER]: 'Driver',
[JobCategory.FARMER]: 'Farmer',
[JobCategory.HOUSEWIFE]: 'Housewife',
[JobCategory.DIPLOMAT]: 'Diplomat',
[JobCategory.STUDENT]: 'Student',
[JobCategory.FREELANCER]: 'Freelancer',
[JobCategory.SHEPHERD]: 'Shepherd',
[JobCategory.HOUSEMAID_OR_BABYSITTER]: 'Housemaid/Babysitter',
[JobCategory.CAPTAIN]: 'Captain',
[JobCategory.AMBASSADOR]: 'Ambassador',
[JobCategory.MARKETING]: 'Marketing',
[JobCategory.CONSULTING]: 'Consulting',
[JobCategory.SUPERVISOR]: 'Supervisor',
[JobCategory.BANKER]: 'Banker',
[JobCategory.BODYGUARD_OR_PERSONAL_ASSISTANT]: 'Bodyguard/Personal Assistant',
}),
incomeRanges: this.enumToOptions(IncomeRange, {
[IncomeRange.BELOW_2000]: 'SAR 2,000 and below',
[IncomeRange.RANGE_2000_5000]: 'SAR 2,000 to 5,000',
[IncomeRange.RANGE_5000_10000]: 'SAR 5,000 to 10,000',
[IncomeRange.RANGE_10000_20000]: 'SAR 10,000 to 20,000',
[IncomeRange.ABOVE_20000]: 'SAR 20,000 and above',
}),
};
}
private enumToOptions(enumObj: any, labels: Record<string, string>): MetadataOptionDto[] {
return Object.keys(enumObj).map((key) => ({
value: enumObj[key],
label: labels[enumObj[key]] || enumObj[key],
}));
}
}

View File

@ -0,0 +1,65 @@
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
} from 'class-validator';
import { PoiType } from '../enums';
@ValidatorConstraint({ name: 'IsValidPoiNumber', async: false })
export class IsValidPoiNumberConstraint implements ValidatorConstraintInterface {
validate(poiNumber: string, args: ValidationArguments) {
const object = args.object as any;
const poiType = object.poiType;
if (!poiNumber || !poiType) {
return false;
}
// Saudi National ID: 10 digits, typically starts with 1 or 2
const nationalIdPattern = /^[12]\d{9}$/;
// Iqama (Resident ID): 10 digits, typically starts with other numbers (not 1 or 2)
const iqamaPattern = /^[3-9]\d{9}$/;
if (poiType === PoiType.NAT) {
return nationalIdPattern.test(poiNumber);
}
if (poiType === PoiType.IQA) {
return iqamaPattern.test(poiNumber);
}
return false;
}
defaultMessage(args: ValidationArguments) {
const object = args.object as any;
const poiType = object.poiType;
if (poiType === PoiType.NAT) {
return 'National ID must be 10 digits and start with 1 or 2';
}
if (poiType === PoiType.IQA) {
return 'Iqama number must be 10 digits and start with 3-9';
}
return 'Invalid POI number format';
}
}
export function IsValidPoiNumber(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsValidPoiNumberConstraint,
});
};
}

View File

@ -19,6 +19,10 @@
"TOKEN_EXPIRED": "رمز المستخدم منتهي الصلاحية." "TOKEN_EXPIRED": "رمز المستخدم منتهي الصلاحية."
}, },
"QR": {
"CODE_USED_OR_EXPIRED": "تم استخدام رمز QR مسبقًا أو انتهت صلاحيته."
},
"USER": { "USER": {
"PHONE_ALREADY_VERIFIED": "تم التحقق من رقم الهاتف بالفعل.", "PHONE_ALREADY_VERIFIED": "تم التحقق من رقم الهاتف بالفعل.",
"EMAIL_ALREADY_VERIFIED": "تم التحقق من عنوان البريد الإلكتروني بالفعل.", "EMAIL_ALREADY_VERIFIED": "تم التحقق من عنوان البريد الإلكتروني بالفعل.",

View File

@ -19,6 +19,10 @@
"TOKEN_EXPIRED": "The user token has expired." "TOKEN_EXPIRED": "The user token has expired."
}, },
"QR": {
"CODE_USED_OR_EXPIRED": "The QR code has already been used or expired."
},
"USER": { "USER": {
"PHONE_ALREADY_VERIFIED": "The phone number has already been verified.", "PHONE_ALREADY_VERIFIED": "The phone number has already been verified.",
"EMAIL_ALREADY_VERIFIED": "The email address has already been verified.", "EMAIL_ALREADY_VERIFIED": "The email address has already been verified.",

View File

@ -151,11 +151,17 @@ export class JuniorController {
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN) @AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(WeeklySummaryResponseDto) @ApiDataResponse(WeeklySummaryResponseDto)
@ApiQuery({ name: 'startUtc', required: false, type: String, example: '2025-10-20T00:00:00.000Z', description: 'Start date (defaults to start of current week)' })
@ApiQuery({ name: 'endUtc', required: false, type: String, example: '2025-10-26T23:59:59.999Z', description: 'End date (defaults to end of current week)' })
async getWeeklySummary( async getWeeklySummary(
@Param('juniorId', CustomParseUUIDPipe) juniorId: string, @Param('juniorId', CustomParseUUIDPipe) juniorId: string,
@AuthenticatedUser() user: IJwtPayload, @AuthenticatedUser() user: IJwtPayload,
@Query('startUtc') startUtc?: string,
@Query('endUtc') endUtc?: string,
) { ) {
const summary = await this.juniorService.getWeeklySummary(juniorId, user.sub); const startDate = startUtc ? new Date(startUtc) : undefined;
const endDate = endUtc ? new Date(endUtc) : undefined;
const summary = await this.juniorService.getWeeklySummary(juniorId, user.sub, startDate, endDate);
return ResponseFactory.data(summary); return ResponseFactory.data(summary);
} }

View File

@ -5,6 +5,7 @@ import { Roles } from '~/auth/enums';
import { CardService, TransactionService } from '~/card/services'; import { CardService, TransactionService } from '~/card/services';
import { NeoLeapService } from '~/common/modules/neoleap/services'; import { NeoLeapService } from '~/common/modules/neoleap/services';
import { PageOptionsRequestDto } from '~/core/dtos'; import { PageOptionsRequestDto } from '~/core/dtos';
import { ErrorCategory } from '~/core/enums';
import { setIf } from '~/core/utils'; import { setIf } from '~/core/utils';
import { CustomerService } from '~/customer/services'; import { CustomerService } from '~/customer/services';
import { DocumentService, OciService } from '~/document/services'; import { DocumentService, OciService } from '~/document/services';
@ -113,7 +114,28 @@ export class JuniorService {
} }
junior.customer.user.email = body.email; junior.customer.user.email = body.email;
} }
setIf(user, 'profilePictureId', body.profilePictureId); // Update profile picture: ensure FK and relation are consistent to avoid TypeORM overriding the FK
if (typeof body.profilePictureId !== 'undefined') {
if (body.profilePictureId) {
const document = await this.documentService.findDocumentById(body.profilePictureId);
if (!document) {
this.logger.error(`Document with id ${body.profilePictureId} not found`);
throw new BadRequestException('DOCUMENT.NOT_FOUND');
}
if (document.createdById !== juniorId) {
this.logger.error(
`Document with id ${body.profilePictureId} does not belong to user ${juniorId}`,
);
}
user.profilePictureId = body.profilePictureId;
// assign relation to keep it consistent with FK during save
user.profilePicture = document as any;
} else {
// if empty string provided (unlikely), clear relation and FK
user.profilePicture = null as any;
user.profilePictureId = null as any;
}
}
setIf(user, 'firstName', body.firstName); setIf(user, 'firstName', body.firstName);
setIf(user, 'lastName', body.lastName); setIf(user, 'lastName', body.lastName);
@ -125,7 +147,7 @@ export class JuniorService {
setIf(junior, 'relationship', body.relationship); setIf(junior, 'relationship', body.relationship);
await Promise.all([junior.save(), customer.save(), user.save()]); await Promise.all([junior.save(), customer.save(), user.save()]);
this.logger.log(`Junior ${juniorId} updated successfully`); this.logger.log(`Junior ${juniorId} updated successfully`);
return junior; return this.findJuniorById(juniorId, false, guardianId);
} }
@Transactional() @Transactional()
@ -158,7 +180,14 @@ export class JuniorService {
async validateToken(token: string) { async validateToken(token: string) {
this.logger.log(`Validating token ${token}`); this.logger.log(`Validating token ${token}`);
const juniorId = await this.userTokenService.validateToken(token, UserType.JUNIOR); const juniorId = await this.userTokenService.validateToken(token, UserType.JUNIOR);
return this.findJuniorById(juniorId!, true); const junior = await this.findJuniorById(juniorId!, true);
if (junior.customer?.user?.password) {
this.logger.error(`Token ${token} already used for junior ${juniorId}`);
throw new BadRequestException({ message: 'QR.CODE_USED_OR_EXPIRED', category: ErrorCategory.BUSINESS_ERROR });
}
return junior;
} }
async generateToken(juniorId: string) { async generateToken(juniorId: string) {
@ -212,7 +241,7 @@ export class JuniorService {
this.logger.log(`Junior ${juniorId} deleted successfully`); this.logger.log(`Junior ${juniorId} deleted successfully`);
} }
async getWeeklySummary(juniorId: string, guardianId: string) { async getWeeklySummary(juniorId: string, guardianId: string, startDate?: Date, endDate?: Date) {
const doesBelong = await this.doesJuniorBelongToGuardian(guardianId, juniorId); const doesBelong = await this.doesJuniorBelongToGuardian(guardianId, juniorId);
if (!doesBelong) { if (!doesBelong) {
@ -221,7 +250,7 @@ export class JuniorService {
} }
this.logger.log(`Getting weekly summary for junior ${juniorId}`); this.logger.log(`Getting weekly summary for junior ${juniorId}`);
return this.cardService.getWeeklySummary(juniorId); return this.cardService.getWeeklySummary(juniorId, startDate, endDate);
} }
async getJuniorHome(juniorId: string, userId: string, size: number): Promise<JuniorHomeResponseDto> { async getJuniorHome(juniorId: string, userId: string, size: number): Promise<JuniorHomeResponseDto> {

View File

@ -226,7 +226,15 @@ export class UserService {
if (userWithEmail) { if (userWithEmail) {
if (userWithEmail.id === userId) { if (userWithEmail.id === userId) {
return; this.logger.log(`Generating OTP for current email ${email} for user ${userId}`);
await this.userRepository.update(userId, { isEmailVerified: false });
return this.otpService.generateAndSendOtp({
userId,
recipient: email,
otpType: OtpType.EMAIL,
scope: OtpScope.VERIFY_EMAIL,
});
} }
this.logger.error(`Email ${email} is already taken by another user`); this.logger.error(`Email ${email} is already taken by another user`);