Compare commits

...

35 Commits

Author SHA1 Message Date
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
bbeece9e03 git checkout -b ZOD-349-weekly-spending-data-not-displaying-in-the-child-profile-parent-view 2025-10-26 13:14:35 +03:00
596562f6dc Merge pull request #48 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-10-21 14:56:38 +03:00
10de8f69c9 Merge pull request #47 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
Remove duplicate email cleanup logic and add unique constraint to use…
2025-10-21 14:15:03 +03:00
8a6b1cc900 Remove duplicate email cleanup logic and add unique constraint to user email 2025-10-21 14:10:14 +03:00
d16ae66252 Merge pull request #46 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
ZOD-341-Add unique constraint to user email and clean up duplicates
2025-10-21 10:51:12 +03:00
e966f95463 ZOD-341-Add unique constraint to user email and clean up duplicates 2025-10-21 10:49:43 +03:00
2714255dd1 Merge pull request #45 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
ZOD-341 Add email uniqueness validation to prevent duplicate emails
2025-10-20 14:31:11 +03:00
39a0b131b8 Merge pull request #44 from HamzaSha1/ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login
Zod 341 junior a child can edit their email to an existing email causing multiple child accounts to share the same login
2025-10-20 14:27:40 +03:00
4f778f7904 * ZOD-341-junior-a-child-can-edit-their-email-to-an-existing-email-causing-multiple-child-accounts-to-share-the-same-login 2025-10-20 14:25:53 +03:00
7e9bc397a9 Merge pull request #43 from HamzaSha1/ZOD-204-view-spending-from-child-login
git checkout -b ZOD-204-view-spending-from-child-login
2025-10-20 10:30:27 +03:00
7bfc14f0d9 Merge pull request #42 from HamzaSha1/ZOD-204-view-spending-from-child-login
ZOD-204-view-spending-from-child-login
2025-10-19 15:44:16 +03:00
d2e084d3e4 git checkout -b ZOD-204-view-spending-from-child-login 2025-10-19 15:26:47 +03:00
f81714a525 Merge pull request #41 from HamzaSha1/ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
2025-10-19 11:07:39 +03:00
f3282a680b Merge pull request #40 from HamzaSha1/ZOD-339-child-profile-gender-update-is-not-reflected-after-editing
Zod 339 child profile gender update is not reflected after editing
2025-10-19 11:02:40 +03:00
7b57277a7f ZOD-339-child-profile-gender-update-is-not-reflected-after-editing 2025-10-19 11:01:52 +03:00
fdd2e23669 Merge pull request #39 from HamzaSha1/ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code
ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instea
2025-10-19 10:47:51 +03:00
d70ab09960 Merge pull request #38 from HamzaSha1/ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code
Zod 333 junior incorrect relationship label displayed as child instead of daughter or son in child confirmation details after the scan the qr code
2025-10-19 09:58:57 +03:00
297a2fe5ad ZOD-333-junior-incorrect-relationship-label-displayed-as-child-instead-of-daughter-or-son-in-child-confirmation-details-after-the-scan-the-qr-code 2025-10-19 09:57:35 +03:00
33b4f13ec8 Merge pull request #37 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-10-16 14:50:23 +03:00
310233c519 Merge pull request #36 from HamzaSha1/ZOD-309-child-transaction-history-parent-→-child-transfers
ZOD-309-child-transaction-history-parent-→-child-transfers
2025-10-16 12:26:50 +03:00
15621124ad ZOD-309-child-transaction-history-parent-→-child-transfers 2025-10-16 12:25:16 +03:00
7fc1918de0 Merge pull request #35 from HamzaSha1/feat/parent-topups-and-child-transfers
feat: add guardian transactions feature with response DTOs and service integration
2025-10-15 14:17:08 +03:00
f6fa74897a feat: add guardian transactions feature with response DTOs and service integration 2025-10-15 14:14:59 +03:00
dd6886ff2b Merge pull request #34 from HamzaSha1/feat/neoleap-integration
match the neoleap-integration branch with dev
2025-10-14 12:20:19 +03:00
649191f3f4 Merge pull request #33 from HamzaSha1/fix/customer-gender-missing-in-get-profile
fix: add gender property to UserResponseDto
2025-10-14 12:14:01 +03:00
183f6b4475 fix: add gender property to UserResponseDto
fix: add gender property to UserResponseDto
2025-10-12 16:06:39 +03:00
8f601b26ae fix: add gender property to UserResponseDto 2025-10-12 16:03:25 +03:00
918b15c315 fix: add swagger 2025-09-23 09:00:41 +03:00
1830d92cbd feat: weekly stats for junior 2025-09-23 08:56:57 +03:00
44124b9964 Merge branch 'dev' of github.com:HamzaSha0/zod-backend into dev 2025-09-18 10:03:47 +03:00
454ded627f fix: fix transfer to child bug 2025-09-16 21:01:32 +03:00
f1484e125b feat: soft delete junior 2025-09-15 09:02:56 +03:00
df4d2e3c1f feat: get card by child id 2025-09-15 08:47:56 +03:00
11712bedf3 Merge pull request #31 from HamzaSha1/feat/neoleap-integration
Feat/neoleap integration
2025-09-08 21:34:46 +03:00
50 changed files with 1293 additions and 25 deletions

View File

@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Gender } from '~/customer/enums';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { User } from '~/user/entities';
@ -33,6 +34,10 @@ export class UserResponseDto {
@ApiProperty()
isEmailVerified!: boolean;
@ApiPropertyOptional({ enum: Gender, nullable: true })
gender!: Gender | null;
constructor(user: User) {
this.id = user.id;
this.countryCode = user.countryCode;
@ -44,5 +49,6 @@ export class UserResponseDto {
this.profilePicture = user.profilePicture ? new DocumentMetaResponseDto(user.profilePicture) : null;
this.isEmailVerified = user.isEmailVerified;
this.isPhoneVerified = user.isPhoneVerified;
this.gender = (user.customer?.gender as Gender) || null;
}
}

View File

@ -34,6 +34,15 @@ export class CardsController {
return ResponseFactory.data(cards.map((card) => new ChildCardResponseDto(card)));
}
@Get('child-cards/:childid')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(ChildCardResponseDto)
async getChildCardById(@Param('childid') childId: string, @AuthenticatedUser() { sub }: IJwtPayload) {
const card = await this.cardService.getCardByChildId(sub, childId);
return ResponseFactory.data(new ChildCardResponseDto(card));
}
@Get('child-cards/:cardid/embossing-details')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)

View File

@ -58,7 +58,9 @@ export class CardResponseDto {
this.status = card.status;
this.statusDescription = CardStatusDescriptionMapper[card.statusDescription][UserLocale.ENGLISH].description;
this.balance =
card.customerType === CustomerType.CHILD ? Math.min(card.limit, card.account.balance) : card.account.balance;
card.customerType === CustomerType.CHILD
? Math.min(card.limit, card.account.balance)
: card.account.balance - card.account.reservedBalance;
this.reservedBalance = card.customerType === CustomerType.PARENT ? card.account.reservedBalance : null;
}
}

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
export class ChildTransferItemDto {
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
date!: Date;
@ApiProperty({ example: 50.0 })
amount!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiProperty({ example: 'You received {{amount}} {{currency}} from your parent.' })
message!: string;
}

View File

@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { TransactionItemResponseDto } from './transaction-item.response.dto';
export class GuardianHomeResponseDto {
@ApiProperty({ example: 2000.0 })
availableBalance!: number;
@ApiProperty({ type: [TransactionItemResponseDto] })
recentTransactions!: TransactionItemResponseDto[];
constructor(availableBalance: number, recentTransactions: TransactionItemResponseDto[]) {
this.availableBalance = availableBalance;
this.recentTransactions = recentTransactions;
}
}

View File

@ -1,3 +1,15 @@
export * from './account-iban.response.dto';
export * from './card.response.dto';
export * from './child-card.response.dto';
export * from './transaction-item.response.dto';
export * from './guardian-home.response.dto';
export * from './paged-transactions.response.dto';
export * from './parent-transfer-item.response.dto';
export * from './parent-home.response.dto';
export * from './paged-parent-transfers.response.dto';
export * from './child-transfer-item.response.dto';
export * from './junior-home.response.dto';
export * from './paged-child-transfers.response.dto';
export * from './spending-history-item.response.dto';
export * from './spending-history.response.dto';
export * from './transaction-detail.response.dto';

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { ChildTransferItemDto } from './child-transfer-item.response.dto';
export class JuniorHomeResponseDto {
@ApiProperty({ example: 500.0 })
availableBalance!: number;
@ApiProperty({ type: [ChildTransferItemDto] })
recentTransfers!: ChildTransferItemDto[];
constructor(availableBalance: number, recentTransfers: ChildTransferItemDto[]) {
this.availableBalance = availableBalance;
this.recentTransfers = recentTransfers;
}
}

View File

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { ChildTransferItemDto } from './child-transfer-item.response.dto';
export class PagedChildTransfersResponseDto {
@ApiProperty({ type: [ChildTransferItemDto] })
items!: ChildTransferItemDto[];
@ApiProperty({ example: 1 })
page!: number;
@ApiProperty({ example: 10 })
size!: number;
@ApiProperty({ example: 20 })
total!: number;
@ApiProperty({ example: true })
hasMore!: boolean;
constructor(
items: ChildTransferItemDto[],
page: number,
size: number,
total: number,
) {
this.items = items;
this.page = page;
this.size = size;
this.total = total;
this.hasMore = page * size < total;
}
}

View File

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { ParentTransferItemDto } from './parent-transfer-item.response.dto';
export class PagedParentTransfersResponseDto {
@ApiProperty({ type: [ParentTransferItemDto] })
items!: ParentTransferItemDto[];
@ApiProperty({ example: 1 })
page!: number;
@ApiProperty({ example: 10 })
size!: number;
@ApiProperty({ example: 45 })
total!: number;
@ApiProperty({ example: true })
hasMore!: boolean;
constructor(
items: ParentTransferItemDto[],
page: number,
size: number,
total: number,
) {
this.items = items;
this.page = page;
this.size = size;
this.total = total;
this.hasMore = page * size < total;
}
}

View File

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { TransactionItemResponseDto } from './transaction-item.response.dto';
export class PagedTransactionsResponseDto {
@ApiProperty({ type: [TransactionItemResponseDto] })
items!: TransactionItemResponseDto[];
@ApiProperty({ example: 1 })
page!: number;
@ApiProperty({ example: 10 })
size!: number;
@ApiProperty({ example: 45 })
total!: number;
@ApiProperty({ example: true })
hasMore!: boolean;
constructor(
items: TransactionItemResponseDto[],
page: number,
size: number,
total: number,
) {
this.items = items;
this.page = page;
this.size = size;
this.total = total;
this.hasMore = page * size < total;
}
}

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { ParentTransferItemDto } from './parent-transfer-item.response.dto';
export class ParentHomeResponseDto {
@ApiProperty({ example: 2000.0 })
availableBalance!: number;
@ApiProperty({ type: [ParentTransferItemDto] })
recentTransfers!: ParentTransferItemDto[];
constructor(availableBalance: number, recentTransfers: ParentTransferItemDto[]) {
this.availableBalance = availableBalance;
this.recentTransfers = recentTransfers;
}
}

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
export class ParentTransferItemDto {
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
date!: Date;
@ApiProperty({ example: 50.0 })
amount!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiProperty({ example: 'Ahmed Ali' })
childName!: string;
}

View File

@ -0,0 +1,58 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transaction } from '~/card/entities/transaction.entity';
export class SpendingHistoryItemDto {
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
date!: Date;
@ApiProperty({ example: 50.5 })
amount!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiPropertyOptional({ example: 'Shopping' })
category!: string | null;
@ApiPropertyOptional({ example: 'Target Store' })
merchantName!: string | null;
@ApiPropertyOptional({ example: 'Riyadh' })
merchantCity!: string | null;
@ApiProperty({ example: '277012*****3456' })
cardMasked!: string;
@ApiProperty()
transactionId!: string;
constructor(transaction: Transaction) {
this.date = transaction.transactionDate;
this.amount = transaction.transactionAmount;
this.currency = transaction.transactionCurrency === '682' ? 'SAR' : transaction.transactionCurrency;
this.category = this.mapMccToCategory(transaction.merchantCategoryCode);
this.merchantName = transaction.merchantName;
this.merchantCity = transaction.merchantCity;
this.cardMasked = transaction.cardMaskedNumber;
this.transactionId = transaction.id;
}
private mapMccToCategory(mcc: string | null): string {
if (!mcc) return 'Other';
const mccCode = mcc;
// Map MCC codes to categories
if (mccCode >= '5200' && mccCode <= '5599') return 'Shopping';
if (mccCode >= '5800' && mccCode <= '5899') return 'Food & Dining';
if (mccCode >= '3000' && mccCode <= '3999') return 'Travel';
if (mccCode >= '4000' && mccCode <= '4799') return 'Transportation';
if (mccCode >= '7200' && mccCode <= '7999') return 'Entertainment';
if (mccCode >= '5900' && mccCode <= '5999') return 'Services';
if (mccCode >= '4800' && mccCode <= '4899') return 'Utilities';
if (mccCode >= '8000' && mccCode <= '8999') return 'Health & Wellness';
return 'Other';
}
}

View File

@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { SpendingHistoryItemDto } from './spending-history-item.response.dto';
export class SpendingHistoryResponseDto {
@ApiProperty({ type: [SpendingHistoryItemDto] })
transactions!: SpendingHistoryItemDto[];
@ApiProperty({ example: 150.75 })
totalSpent!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiProperty({ example: 10 })
count!: number;
constructor(transactions: SpendingHistoryItemDto[], currency: string = 'SAR') {
this.transactions = transactions;
this.totalSpent = transactions.reduce((sum, tx) => sum + tx.amount, 0);
this.currency = currency;
this.count = transactions.length;
}
}

View File

@ -0,0 +1,74 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transaction } from '~/card/entities/transaction.entity';
export class TransactionDetailResponseDto {
@ApiProperty()
id!: string;
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
date!: Date;
@ApiProperty({ example: 50.5 })
amount!: number;
@ApiProperty({ example: 'SAR' })
currency!: string;
@ApiProperty({ example: 2.5 })
fees!: number;
@ApiProperty({ example: 0.5 })
vatOnFees!: number;
@ApiPropertyOptional({ example: 'Target Store' })
merchantName!: string | null;
@ApiPropertyOptional({ example: 'Shopping' })
category!: string | null;
@ApiPropertyOptional({ example: 'Riyadh' })
merchantCity!: string | null;
@ApiProperty({ example: '277012*****3456' })
cardMasked!: string;
@ApiProperty()
rrn!: string;
@ApiProperty()
transactionId!: string;
constructor(transaction: Transaction) {
this.id = transaction.id;
this.date = transaction.transactionDate;
this.amount = transaction.transactionAmount;
this.currency = transaction.transactionCurrency === '682' ? 'SAR' : transaction.transactionCurrency;
this.fees = transaction.fees;
this.vatOnFees = transaction.vatOnFees;
this.merchantName = transaction.merchantName;
this.category = this.mapMccToCategory(transaction.merchantCategoryCode);
this.merchantCity = transaction.merchantCity;
this.cardMasked = transaction.cardMaskedNumber;
this.rrn = transaction.rrn;
this.transactionId = transaction.transactionId;
}
private mapMccToCategory(mcc: string | null): string {
if (!mcc) return 'Other';
const mccCode = mcc;
// Map MCC codes to categories
if (mccCode >= '5200' && mccCode <= '5599') return 'Shopping';
if (mccCode >= '5800' && mccCode <= '5899') return 'Food & Dining';
if (mccCode >= '3000' && mccCode <= '3999') return 'Travel';
if (mccCode >= '4000' && mccCode <= '4799') return 'Transportation';
if (mccCode >= '7200' && mccCode <= '7999') return 'Entertainment';
if (mccCode >= '5900' && mccCode <= '5999') return 'Services';
if (mccCode >= '4800' && mccCode <= '4899') return 'Utilities';
if (mccCode >= '8000' && mccCode <= '8999') return 'Health & Wellness';
return 'Other';
}
}

View File

@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { ParentTransactionType } from '~/card/enums';
export class TransactionItemResponseDto {
@ApiProperty()
date!: Date;
@ApiProperty({ example: -50.0 })
amountSigned!: number;
@ApiProperty({ enum: ParentTransactionType })
type!: ParentTransactionType;
@ApiProperty({ description: 'Counterparty display name (child for transfer, source label for top-up)' })
counterpartyName!: string;
@ApiProperty({ nullable: true })
counterpartyAccountMasked!: string | null;
@ApiProperty({ required: false })
childName?: string;
}

View File

@ -22,10 +22,28 @@ export class Account {
@Column('varchar', { length: 255, nullable: false, name: 'currency' })
currency!: string;
@Column('decimal', { precision: 10, scale: 2, default: 0.0, name: 'balance' })
@Column('decimal', {
precision: 10,
scale: 2,
default: 0.0,
name: 'balance',
transformer: {
to: (value: number) => value,
from: (value: string) => parseFloat(value),
},
})
balance!: number;
@Column('decimal', { precision: 10, scale: 2, default: 0.0, name: 'reserved_balance' })
@Column('decimal', {
precision: 10,
scale: 2,
default: 0.0,
name: 'reserved_balance',
transformer: {
to: (value: number) => value,
from: (value: string) => parseFloat(value),
},
})
reservedBalance!: number;
@OneToMany(() => Card, (card) => card.account, { cascade: true })

View File

@ -32,7 +32,16 @@ export class Transaction {
@Column({ name: 'rrn', nullable: true, type: 'varchar' })
rrn!: string;
@Column({ type: 'decimal', precision: 12, scale: 2, name: 'transaction_amount' })
@Column({
type: 'decimal',
precision: 12,
scale: 2,
name: 'transaction_amount',
transformer: {
to: (value: number) => value,
from: (value: string) => parseFloat(value),
},
})
transactionAmount!: number;
@Column({ type: 'varchar', name: 'transaction_currency' })
@ -50,6 +59,15 @@ export class Transaction {
@Column({ type: 'decimal', name: 'vat_on_fees', precision: 12, scale: 2, default: 0.0 })
vatOnFees!: number;
@Column({ name: 'merchant_name', type: 'varchar', nullable: true })
merchantName!: string | null;
@Column({ name: 'merchant_category_code', type: 'varchar', nullable: true })
merchantCategoryCode!: string | null;
@Column({ name: 'merchant_city', type: 'varchar', nullable: true })
merchantCity!: string | null;
@Column({ name: 'card_id', type: 'uuid', nullable: true })
cardId!: string;

View File

@ -6,3 +6,4 @@ export * from './card-status.enum';
export * from './customer-type.enum';
export * from './transaction-scope.enum';
export * from './transaction-type.enum';
export * from './parent-transaction-type.enum';

View File

@ -0,0 +1,6 @@
export enum ParentTransactionType {
PARENT_TRANSFER = 'PARENT_TRANSFER',
PARENT_TOPUP = 'PARENT_TOPUP',
}

View File

@ -45,6 +45,13 @@ export class CardRepository {
return this.cardRepository.findOne({ where: { id }, relations: ['account'] });
}
findCardByChildId(guardianId: string, childId: string): Promise<Card | null> {
return this.cardRepository.findOne({
where: { parentId: guardianId, customerId: childId, customerType: CustomerType.CHILD },
relations: ['account', 'customer', 'customer.user', 'customer.user.profilePicture', 'customer.junior'],
});
}
getCardByReferenceNumber(referenceNumber: string): Promise<Card | null> {
return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] });
}

View File

@ -1 +1,3 @@
export * from './card.repository';
export * from './transaction.repository';
export * from './account.repository';

View File

@ -34,6 +34,9 @@ export class TransactionRepository {
accountReference: card.account!.accountReference,
transactionScope: TransactionScope.CARD,
vatOnFees: transactionData.vatOnFees,
merchantName: transactionData.cardAcceptorLocation?.merchantName || null,
merchantCategoryCode: transactionData.cardAcceptorLocation?.mcc || null,
merchantCity: transactionData.cardAcceptorLocation?.merchantCity || null,
}),
);
}
@ -84,4 +87,97 @@ export class TransactionRepository {
where: { transactionId, accountReference },
});
}
getTransactionsForCardWithinDateRange(juniorId: string, startDate: Date, endDate: Date): Promise<Transaction[]> {
return this.transactionRepository
.createQueryBuilder('transaction')
.innerJoinAndSelect('transaction.card', 'card')
.where('card.customerId = :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 })
.orderBy('transaction.transactionDate', 'DESC')
.getMany();
}
findParentTransfers(guardianCustomerId: string, skip: number, take: number): Promise<Transaction[]> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.card', 'card')
.innerJoinAndSelect('card.customer', 'childCustomer')
.innerJoinAndSelect('card.account', 'account')
.where('card.parentId = :guardianCustomerId', { guardianCustomerId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.orderBy('tx.transactionDate', 'DESC')
.skip(skip)
.take(take)
.getMany();
}
findParentTopups(guardianCustomerId: string, skip: number, take: number): Promise<Transaction[]> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.account', 'account')
.leftJoinAndSelect('account.cards', 'parentCards')
.where('tx.transactionScope = :scope', { scope: TransactionScope.ACCOUNT })
.andWhere('parentCards.customerId = :guardianCustomerId', { guardianCustomerId })
.orderBy('tx.transactionDate', 'DESC')
.skip(skip)
.take(take)
.getMany();
}
countParentTransfers(guardianCustomerId: string): Promise<number> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoin('tx.card', 'card')
.where('card.parentId = :guardianCustomerId', { guardianCustomerId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.getCount();
}
countParentTopups(guardianCustomerId: string): Promise<number> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoin('tx.account', 'account')
.leftJoin('account.cards', 'parentCards')
.where('tx.transactionScope = :scope', { scope: TransactionScope.ACCOUNT })
.andWhere('parentCards.customerId = :guardianCustomerId', { guardianCustomerId })
.getCount();
}
findTransfersToJunior(juniorId: string, skip: number, take: number): Promise<Transaction[]> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.card', 'card')
.innerJoinAndSelect('card.account', 'account')
.where('card.customerId = :juniorId', { juniorId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.orderBy('tx.transactionDate', 'DESC')
.skip(skip)
.take(take)
.getMany();
}
countTransfersToJunior(juniorId: string): Promise<number> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoin('tx.card', 'card')
.where('card.customerId = :juniorId', { juniorId })
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
.getCount();
}
findTransactionById(transactionId: string, juniorId: string): Promise<Transaction | null> {
return this.transactionRepository
.createQueryBuilder('tx')
.innerJoinAndSelect('tx.card', 'card')
.where('tx.id = :transactionId', { transactionId })
.andWhere('card.customerId = :juniorId', { juniorId })
.getOne();
}
}

View File

@ -65,7 +65,7 @@ export class AccountService {
increaseReservedBalance(account: Account, amount: number) {
if (account.balance < account.reservedBalance + amount) {
throw new UnprocessableEntityException('ACCOUNT.INSUFFICIENT_BALANCE');
throw new UnprocessableEntityException('CARD.INSUFFICIENT_BALANCE');
}
return this.accountRepository.increaseReservedBalance(account.id, amount);
}

View File

@ -63,6 +63,15 @@ export class CardService {
return this.getCardById(createdCard.id);
}
async getCardByChildId(guardianId: string, childId: string): Promise<Card> {
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<Card> {
const card = await this.cardRepository.getCardById(id);
@ -154,6 +163,10 @@ export class CardService {
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);
}

View File

@ -1 +1,3 @@
export * from './card.service';
export * from './transaction.service';
export * from './account.service';

View File

@ -1,15 +1,25 @@
import { forwardRef, Inject, Injectable, UnprocessableEntityException } from '@nestjs/common';
import Decimal from 'decimal.js';
import moment from 'moment';
import { Transactional } from 'typeorm-transactional';
import {
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
} from '~/common/modules/neoleap/dtos/requests';
import { Transaction } from '../entities/transaction.entity';
import { CustomerType } from '../enums';
import { CustomerType, TransactionType } from '../enums';
import { TransactionRepository } from '../repositories/transaction.repository';
import { AccountService } from './account.service';
import { CardService } from './card.service';
import {
TransactionItemResponseDto,
PagedTransactionsResponseDto,
ParentTransferItemDto,
PagedParentTransfersResponseDto,
ChildTransferItemDto,
PagedChildTransfersResponseDto,
} from '../dtos/responses';
import { ParentTransactionType } from '../enums';
@Injectable()
export class TransactionService {
@ -73,4 +83,233 @@ export class TransactionService {
return existingTransaction;
}
async getWeeklySummary(juniorId: string, startDate?: Date, endDate?: Date) {
let startOfWeek: Date;
let endOfWeek: Date;
if (startDate && endDate) {
startOfWeek = startDate;
endOfWeek = endDate;
} else {
const now = moment();
const dayOfWeek = now.day();
startOfWeek = moment().subtract(dayOfWeek, 'days').startOf('day').toDate();
endOfWeek = moment().add(6 - dayOfWeek, 'days').endOf('day').toDate();
}
const transactions = await this.transactionRepository.getTransactionsForCardWithinDateRange(
juniorId,
startOfWeek,
endOfWeek,
);
const summary = {
startOfWeek: startOfWeek,
endOfWeek: endOfWeek,
total: 0,
monday: 0,
tuesday: 0,
wednesday: 0,
thursday: 0,
friday: 0,
saturday: 0,
sunday: 0,
};
transactions.forEach((transaction) => {
const day = moment(transaction.transactionDate).format('dddd').toLowerCase() as
| 'monday'
| 'tuesday'
| 'wednesday'
| 'thursday'
| 'friday'
| 'saturday'
| 'sunday';
summary[day] += transaction.transactionAmount;
});
summary.total = transactions.reduce((acc, curr) => acc + curr.transactionAmount, 0);
return summary;
}
async getParentConsolidated(
guardianCustomerId: string,
page: number,
size: number,
): Promise<TransactionItemResponseDto[]> {
const skip = (page - 1) * size;
const [transfers, topups] = await Promise.all([
this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size),
this.transactionRepository.findParentTopups(guardianCustomerId, skip, size),
]);
const merged = [...transfers, ...topups].sort(
(a, b) => new Date(b.transactionDate).getTime() - new Date(a.transactionDate).getTime(),
);
const trimmed = merged.slice(0, size);
return trimmed.map((t) => this.mapParentItem(t));
}
async getParentTransactionsPaginated(
guardianCustomerId: string,
page: number,
size: number,
type?: ParentTransactionType,
): Promise<PagedTransactionsResponseDto> {
const skip = (page - 1) * size;
let transfers: Transaction[] = [];
let topups: Transaction[] = [];
let transferCount = 0;
let topupCount = 0;
if (!type || type === ParentTransactionType.PARENT_TRANSFER) {
[transfers, transferCount] = await Promise.all([
this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size),
this.transactionRepository.countParentTransfers(guardianCustomerId),
]);
}
if (!type || type === ParentTransactionType.PARENT_TOPUP) {
[topups, topupCount] = await Promise.all([
this.transactionRepository.findParentTopups(guardianCustomerId, skip, size),
this.transactionRepository.countParentTopups(guardianCustomerId),
]);
}
const total = transferCount + topupCount;
if (type) {
const items = type === ParentTransactionType.PARENT_TRANSFER ? transfers : topups;
const mapped = items.map((t) => this.mapParentItem(t));
return new PagedTransactionsResponseDto(mapped, page, size, total);
}
const merged = [...transfers, ...topups].sort(
(a, b) => new Date(b.transactionDate).getTime() - new Date(a.transactionDate).getTime(),
);
const paginated = merged.slice(0, size);
const mapped = paginated.map((t) => this.mapParentItem(t));
return new PagedTransactionsResponseDto(mapped, page, size, total);
}
async getParentTransfersOnly(guardianCustomerId: string, page: number, size: number): Promise<ParentTransferItemDto[]> {
const skip = (page - 1) * size;
const transfers = await this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size);
return transfers.map((t) => this.mapToParentTransferItem(t));
}
async getParentTransfersPaginated(
guardianCustomerId: string,
page: number,
size: number,
): Promise<PagedParentTransfersResponseDto> {
const skip = (page - 1) * size;
const [transfers, total] = await Promise.all([
this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size),
this.transactionRepository.countParentTransfers(guardianCustomerId),
]);
const items = transfers.map((t) => this.mapToParentTransferItem(t));
return new PagedParentTransfersResponseDto(items, page, size, total);
}
async getChildTransfers(juniorId: string, page: number, size: number): Promise<ChildTransferItemDto[]> {
const skip = (page - 1) * size;
const transfers = await this.transactionRepository.findTransfersToJunior(juniorId, skip, size);
return transfers.map((t) => this.mapToChildTransferItem(t));
}
async getChildTransfersPaginated(
juniorId: string,
page: number,
size: number,
): Promise<PagedChildTransfersResponseDto> {
const skip = (page - 1) * size;
const [transfers, total] = await Promise.all([
this.transactionRepository.findTransfersToJunior(juniorId, skip, size),
this.transactionRepository.countTransfersToJunior(juniorId),
]);
const items = transfers.map((t) => this.mapToChildTransferItem(t));
return new PagedChildTransfersResponseDto(items, page, size, total);
}
private mapToParentTransferItem(t: Transaction): ParentTransferItemDto {
const child = t.card?.customer;
const currency = t.transactionCurrency === '682' ? 'SAR' : t.transactionCurrency;
return {
date: t.transactionDate,
amount: Math.abs(t.transactionAmount),
currency,
childName: child ? `${child.firstName} ${child.lastName}` : 'Child',
};
}
private mapToChildTransferItem(t: Transaction): ChildTransferItemDto {
const amount = Math.abs(t.transactionAmount);
const currency = t.transactionCurrency === '682' ? 'SAR' : t.transactionCurrency;
return {
date: t.transactionDate,
amount,
currency,
message: `You received {{amount}} {{currency}} from your parent.`,
};
}
async getChildSpendingHistory(juniorId: string, startUtc: Date, endUtc: Date) {
const transactions = await this.transactionRepository.getTransactionsForCardWithinDateRange(
juniorId,
startUtc,
endUtc,
);
const { SpendingHistoryItemDto, SpendingHistoryResponseDto } = await import('../dtos/responses');
const items = transactions.map((t) => new SpendingHistoryItemDto(t));
return new SpendingHistoryResponseDto(items);
}
async getTransactionDetail(transactionId: string, juniorId: string) {
const transaction = await this.transactionRepository.findTransactionById(transactionId, juniorId);
if (!transaction) {
throw new UnprocessableEntityException('TRANSACTION.NOT_FOUND');
}
const { TransactionDetailResponseDto } = await import('../dtos/responses');
return new TransactionDetailResponseDto(transaction);
}
private mapParentItem(t: Transaction): TransactionItemResponseDto {
const dto = new TransactionItemResponseDto();
dto.date = t.transactionDate;
if (t.transactionType === TransactionType.INTERNAL) {
dto.type = ParentTransactionType.PARENT_TRANSFER;
dto.amountSigned = -Math.abs(t.transactionAmount);
const child = t.card?.customer;
dto.counterpartyName = child ? `${child.firstName} ${child.lastName}` : 'Child';
dto.childName = dto.counterpartyName;
dto.counterpartyAccountMasked = t.card?.account?.accountReference
? `****${t.card.account.accountReference.slice(-4)}`
: null;
return dto;
}
dto.type = ParentTransactionType.PARENT_TOPUP;
const settlement = Number(t.settlementAmount ?? 0);
const txn = Number(t.transactionAmount ?? 0);
const creditAmount = settlement > 0 ? settlement : txn;
dto.amountSigned = Math.abs(Number.isFinite(creditAmount) ? creditAmount : 0);
dto.counterpartyName = 'Top-up';
dto.counterpartyAccountMasked = t.accountReference ? `****${t.accountReference.slice(-4)}` : null;
return dto;
}
}

View File

@ -56,12 +56,12 @@ export class AccountTransactionWebhookRequest {
@ApiProperty({ example: '682' })
currency!: string;
@Expose()
@Expose({ name: 'Date' })
@IsString()
@ApiProperty({ name: 'Date', example: '20241112' })
date!: string;
@Expose()
@Expose({ name: 'Time' })
@IsString()
@ApiProperty({ name: 'Time', example: '125340' })
time!: string;

View File

@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDeletedAtColumnToJunior1757915357218 implements MigrationInterface {
name = 'AddDeletedAtColumnToJunior1757915357218';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "juniors" ADD "deleted_at" TIMESTAMP WITH TIME ZONE`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "juniors" DROP COLUMN "deleted_at"`);
}
}

View File

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddMerchantInfoToTransactions1760869651296 implements MigrationInterface {
name = 'AddMerchantInfoToTransactions1760869651296'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "transactions" ADD COLUMN IF NOT EXISTS "merchant_name" character varying`);
await queryRunner.query(`ALTER TABLE "transactions" ADD COLUMN IF NOT EXISTS "merchant_category_code" character varying`);
await queryRunner.query(`ALTER TABLE "transactions" ADD COLUMN IF NOT EXISTS "merchant_city" character varying`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "merchant_city"`);
await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "merchant_category_code"`);
await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "merchant_name"`);
}
}

View File

@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddUniqueConstraintToUserEmail1761032305682 implements MigrationInterface {
name = 'AddUniqueConstraintToUserEmail1761032305682'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3"`);
}
}

View File

@ -3,3 +3,6 @@ export * from './1754915164809-create-neoleap-related-entities';
export * from './1754915164810-seed-default-avatar';
export * from './1757349525708-create-money-requests-table';
export * from './1757433339849-add-reservation-amount-to-account-entity';
export * from './1757915357218-add-deleted-at-column-to-junior';
export * from './1760869651296-AddMerchantInfoToTransactions';
export * from './1761032305682-AddUniqueConstraintToUserEmail';

View File

@ -0,0 +1,50 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags, ApiQuery } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard, RolesGuard } from '~/common/guards';
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { ParentHomeResponseDto, PagedParentTransfersResponseDto } from '~/card/dtos/responses';
import { GuardianTransactionsService } from '../services';
@Controller('guardians/me')
@ApiTags('Guardians')
@ApiBearerAuth()
@ApiLangRequestHeader()
@UseGuards(AccessTokenGuard, RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
export class GuardianTransactionsController {
constructor(private readonly guardianTxService: GuardianTransactionsService) {}
@Get('home')
@ApiQuery({ name: 'size', required: false, type: Number, example: 5 })
@ApiDataResponse(ParentHomeResponseDto)
async getHome(
@AuthenticatedUser() user: IJwtPayload,
@Query('size') size?: number,
) {
const limit = Math.max(1, Math.min(Number(size) || 5, 20));
const res = await this.guardianTxService.getHome(user.sub, limit);
return ResponseFactory.data(res);
}
@Get('transfers')
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
@ApiQuery({ name: 'size', required: false, type: Number, example: 10 })
@ApiDataResponse(PagedParentTransfersResponseDto)
async getTransfers(
@AuthenticatedUser() user: IJwtPayload,
@Query('page') page?: number,
@Query('size') size?: number,
) {
const pageNum = Math.max(1, Number(page) || 1);
const pageSize = Math.max(1, Math.min(Number(size) || 10, 50));
const res = await this.guardianTxService.getTransfers(user.sub, pageNum, pageSize);
return ResponseFactory.data(res);
}
}

View File

@ -0,0 +1,3 @@
export * from './guardian-transactions.controller';

View File

@ -1,12 +1,18 @@
import { Module } from '@nestjs/common';
import { forwardRef } from '@nestjs/common';
import { CustomerModule } from '~/customer/customer.module';
import { CardModule } from '~/card/card.module';
import { GuardianTransactionsController } from './controllers/guardian-transactions.controller';
import { GuardianTransactionsService } from './services/guardian-transactions.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Guardian } from './entities/guradian.entity';
import { GuardianRepository } from './repositories';
import { GuardianService } from './services';
@Module({
providers: [GuardianService, GuardianRepository],
imports: [TypeOrmModule.forFeature([Guardian])],
providers: [GuardianService, GuardianRepository, GuardianTransactionsService],
controllers: [GuardianTransactionsController],
imports: [TypeOrmModule.forFeature([Guardian]), forwardRef(() => CustomerModule), forwardRef(() => CardModule)],
exports: [GuardianService],
})
export class GuardianModule {}

View File

@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { CustomerService } from '~/customer/services';
import { ParentHomeResponseDto, PagedParentTransfersResponseDto } from '~/card/dtos/responses';
import { TransactionService } from '~/card/services/transaction.service';
@Injectable()
export class GuardianTransactionsService {
constructor(
private readonly customerService: CustomerService,
private readonly transactionService: TransactionService,
) {}
async getHome(guardianId: string, size: number): Promise<ParentHomeResponseDto> {
const parent = await this.customerService.findCustomerById(guardianId);
const primaryCard = parent.cards?.[0];
let availableBalance = 0;
if (primaryCard) {
const hasLimit = typeof primaryCard.limit === 'number' && !Number.isNaN(primaryCard.limit);
const hasBalance = primaryCard.account && typeof primaryCard.account.balance === 'number';
if (hasLimit && hasBalance && primaryCard.limit > 0) {
availableBalance = Math.min(primaryCard.limit, primaryCard.account.balance);
} else if (hasBalance) {
availableBalance = primaryCard.account.balance;
}
}
const recentTransfers = await this.transactionService.getParentTransfersOnly(guardianId, 1, size);
return new ParentHomeResponseDto(availableBalance, recentTransfers);
}
async getTransfers(
guardianId: string,
page: number,
size: number,
): Promise<PagedParentTransfersResponseDto> {
return this.transactionService.getParentTransfersPaginated(guardianId, page, size);
}
}

View File

@ -1 +1,2 @@
export * from './guardian.service';
export * from './guardian.service'
export * from './guardian-transactions.service'

View File

@ -68,7 +68,8 @@
"CIVIL_ID_REQUIRED": "مطلوب بطاقة الهوية المدنية.",
"CIVIL_ID_NOT_CREATED_BY_GUARDIAN": "تم تحميل بطاقة الهوية المدنية من قبل شخص آخر غير ولي الأمر.",
"CIVIL_ID_ALREADY_EXISTS": "بطاقة الهوية المدنية مستخدمة بالفعل من قبل طفل آخر.",
"CANNOT_UPDATE_REGISTERED_USER": "الطفل قد سجل بالفعل. لا يُسمح بتحديث البيانات."
"CANNOT_UPDATE_REGISTERED_USER": "الطفل قد سجل بالفعل. لا يُسمح بتحديث البيانات.",
"CANNOT_DELETE_REGISTERED_USER": "الطفل قد سجل بالفعل. لا يُسمح بحذف الطفل."
},
"MONEY_REQUEST": {
@ -103,6 +104,7 @@
},
"CARD": {
"INSUFFICIENT_BALANCE": "البطاقة لا تحتوي على رصيد كافٍ لإكمال هذا التحويل.",
"DOES_NOT_BELONG_TO_GUARDIAN": "البطاقة لا تنتمي إلى ولي الأمر."
"DOES_NOT_BELONG_TO_GUARDIAN": "البطاقة لا تنتمي إلى ولي الأمر.",
"NOT_FOUND": "لم يتم العثور على البطاقة."
}
}

View File

@ -67,7 +67,8 @@
"CIVIL_ID_REQUIRED": "Civil ID is required.",
"CIVIL_ID_NOT_CREATED_BY_GUARDIAN": "The civil ID document was not uploaded by the guardian.",
"CIVIL_ID_ALREADY_EXISTS": "The civil ID is already used by another junior.",
"CANNOT_UPDATE_REGISTERED_USER": "The junior has already registered. Updating details is not allowed."
"CANNOT_UPDATE_REGISTERED_USER": "The junior has already registered. Updating details is not allowed.",
"CANNOT_DELETE_REGISTERED_USER": "The junior has already registered. Deleting the junior is not allowed."
},
"MONEY_REQUEST": {
@ -102,6 +103,7 @@
},
"CARD": {
"INSUFFICIENT_BALANCE": "The card does not have sufficient balance to complete this transfer.",
"DOES_NOT_BELONG_TO_GUARDIAN": "The card does not belong to the guardian."
"DOES_NOT_BELONG_TO_GUARDIAN": "The card does not belong to the guardian.",
"NOT_FOUND": "The card was not found."
}
}

View File

@ -1,5 +1,17 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags, ApiQuery } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser, Public } from '~/common/decorators';
@ -20,6 +32,8 @@ import {
ThemeResponseDto,
TransferToJuniorResponseDto,
} from '../dtos/response';
import { WeeklySummaryResponseDto } from '../dtos/response/weekly-summary.response.dto';
import { JuniorHomeResponseDto, PagedChildTransfersResponseDto } from '~/card/dtos/responses';
import { JuniorService } from '../services';
@Controller('juniors')
@ -83,6 +97,14 @@ export class JuniorController {
return ResponseFactory.data(new JuniorResponseDto(junior));
}
@Delete(':juniorId')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@HttpCode(HttpStatus.NO_CONTENT)
async deleteJunior(@AuthenticatedUser() user: IJwtPayload, @Param('juniorId', CustomParseUUIDPipe) juniorId: string) {
await this.juniorService.deleteJunior(juniorId, user.sub);
}
@Post('set-theme')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.JUNIOR)
@ -124,4 +146,82 @@ export class JuniorController {
return ResponseFactory.data(new TransferToJuniorResponseDto(newAmount));
}
@Get(':juniorId/weekly-summary')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@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(
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
@AuthenticatedUser() user: IJwtPayload,
@Query('startUtc') startUtc?: string,
@Query('endUtc') endUtc?: string,
) {
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);
}
@Get(':juniorId/home')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN)
@ApiQuery({ name: 'size', required: false, type: Number, example: 5 })
@ApiDataResponse(JuniorHomeResponseDto)
async getJuniorHome(
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
@AuthenticatedUser() user: IJwtPayload,
@Query('size') size?: number,
) {
const limit = Math.max(1, Math.min(Number(size) || 5, 20));
const res = await this.juniorService.getJuniorHome(juniorId, user.sub, limit);
return ResponseFactory.data(res);
}
@Get(':juniorId/transfers')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN)
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
@ApiQuery({ name: 'size', required: false, type: Number, example: 10 })
@ApiDataResponse(PagedChildTransfersResponseDto)
async getJuniorTransfers(
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
@AuthenticatedUser() user: IJwtPayload,
@Query('page') page?: number,
@Query('size') size?: number,
) {
const pageNum = Math.max(1, Number(page) || 1);
const pageSize = Math.max(1, Math.min(Number(size) || 10, 50));
const res = await this.juniorService.getJuniorTransfers(juniorId, user.sub, pageNum, pageSize);
return ResponseFactory.data(res);
}
@Get(':juniorId/spending-history')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN)
@ApiQuery({ name: 'startUtc', required: true, type: String, example: '2025-01-01T00:00:00.000Z' })
@ApiQuery({ name: 'endUtc', required: true, type: String, example: '2025-01-31T23:59:59.999Z' })
async getSpendingHistory(
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
@AuthenticatedUser() user: IJwtPayload,
@Query('startUtc') startUtc: string,
@Query('endUtc') endUtc: string,
) {
const res = await this.juniorService.getSpendingHistory(juniorId, user.sub, new Date(startUtc), new Date(endUtc));
return ResponseFactory.data(res);
}
@Get(':juniorId/transactions/:transactionId')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN)
async getTransactionDetail(
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
@Param('transactionId', CustomParseUUIDPipe) transactionId: string,
@AuthenticatedUser() user: IJwtPayload,
) {
const res = await this.juniorService.getTransactionDetail(juniorId, user.sub, transactionId);
return ResponseFactory.data(res);
}
}

View File

@ -1,7 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { Gender } from '~/customer/enums';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { Junior } from '~/junior/entities';
import { GuardianRelationship } from '~/junior/enums';
import { ChildRelationshipLabel, GuardianRelationship, Relationship } from '~/junior/enums';
export class QrCodeValidationDetailsResponse {
@ApiProperty()
@ -26,6 +27,17 @@ export class QrCodeValidationDetailsResponse {
this.phoneNumber = person.customer.user.phoneNumber;
this.email = person.customer.user.email;
this.dateOfBirth = person.customer.dateOfBirth;
this.relationship = guardian ? junior.relationship : GuardianRelationship[junior.relationship];
if (guardian) {
this.relationship = junior.relationship;
} else {
if (junior.relationship === Relationship.PARENT) {
this.relationship = junior.customer.gender === Gender.MALE
? ChildRelationshipLabel.SON
: ChildRelationshipLabel.DAUGHTER;
} else {
this.relationship = GuardianRelationship[junior.relationship];
}
}
}
}

View File

@ -0,0 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
export class WeeklySummaryResponseDto {
@ApiProperty({ description: 'Start date of the week', example: '2023-10-01' })
startOfWeek!: Date;
@ApiProperty({ description: 'End date of the week', example: '2023-10-07' })
endOfWeek!: Date;
@ApiProperty({ description: 'Total amount spent in the week', example: 350 })
total!: number;
@ApiProperty({ description: 'Amount spent on Sunday', example: 50 })
sunday!: number;
@ApiProperty({ description: 'Amount spent on Monday', example: 30 })
monday!: number;
@ApiProperty({ description: 'Amount spent on Tuesday', example: 20 })
tuesday!: number;
@ApiProperty({ description: 'Amount spent on Wednesday', example: 40 })
wednesday!: number;
@ApiProperty({ description: 'Amount spent on Thursday', example: 60 })
thursday!: number;
@ApiProperty({ description: 'Amount spent on Friday', example: 70 })
friday!: number;
@ApiProperty({ description: 'Amount spent on Saturday', example: 80 })
saturday!: number;
}

View File

@ -2,6 +2,7 @@ import {
BaseEntity,
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
@ -49,4 +50,7 @@ export class Junior extends BaseEntity {
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamp with time zone', nullable: true })
deletedAt!: Date | null;
}

View File

@ -0,0 +1,5 @@
export enum ChildRelationshipLabel {
SON = 'SON',
DAUGHTER = 'DAUGHTER',
}

View File

@ -1,3 +1,4 @@
export * from './child-relationship-label.enum';
export * from './guardian-relationship.enum';
export * from './relationship.enum';
export * from './theme-color.enum';

View File

@ -65,4 +65,8 @@ export class JuniorRepository {
}),
);
}
softDelete(juniorId: string) {
return this.juniorRepository.softDelete({ id: juniorId });
}
}

View File

@ -1,7 +1,8 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
import { Transactional } from 'typeorm-transactional';
import { Roles } from '~/auth/enums';
import { CardService } from '~/card/services';
import { CardService, TransactionService } from '~/card/services';
import { NeoLeapService } from '~/common/modules/neoleap/services';
import { PageOptionsRequestDto } from '~/core/dtos';
import { setIf } from '~/core/utils';
@ -19,6 +20,7 @@ import {
import { Junior } from '../entities';
import { JuniorRepository } from '../repositories';
import { QrcodeService } from './qrcode.service';
import { JuniorHomeResponseDto, PagedChildTransfersResponseDto } from '~/card/dtos/responses';
@Injectable()
export class JuniorService {
@ -33,6 +35,7 @@ export class JuniorService {
private readonly qrCodeService: QrcodeService,
private readonly neoleapService: NeoLeapService,
private readonly cardService: CardService,
private readonly transactionService: TransactionService,
) {}
@Transactional()
@ -117,6 +120,7 @@ export class JuniorService {
setIf(customer, 'firstName', body.firstName);
setIf(customer, 'lastName', body.lastName);
setIf(customer, 'dateOfBirth', body.dateOfBirth as unknown as Date);
setIf(customer, 'gender', body.gender);
setIf(junior, 'relationship', body.relationship);
await Promise.all([junior.save(), customer.save(), user.save()]);
@ -183,6 +187,129 @@ export class JuniorService {
return this.cardService.transferToChild(juniorId, body.amount);
}
async deleteJunior(juniorId: string, guardianId: string) {
const doesBelong = await this.doesJuniorBelongToGuardian(guardianId, juniorId);
if (!doesBelong) {
this.logger.error(`Junior ${juniorId} does not belong to guardian ${guardianId}`);
throw new BadRequestException('JUNIOR.NOT_BELONG_TO_GUARDIAN');
}
const hasPassword = await this.userService.findUser({ id: juniorId, password: Not(IsNull()) });
if (hasPassword) {
this.logger.error(`Cannot delete junior ${juniorId} with registered user`);
throw new BadRequestException('JUNIOR.CANNOT_DELETE_REGISTERED_USER');
}
const { affected } = await this.juniorRepository.softDelete(juniorId);
if (affected === 0) {
this.logger.error(`Junior ${juniorId} not found`);
throw new BadRequestException('JUNIOR.NOT_FOUND');
}
this.logger.log(`Junior ${juniorId} deleted successfully`);
}
async getWeeklySummary(juniorId: string, guardianId: string, startDate?: Date, endDate?: Date) {
const doesBelong = await this.doesJuniorBelongToGuardian(guardianId, juniorId);
if (!doesBelong) {
this.logger.error(`Junior ${juniorId} does not belong to guardian ${guardianId}`);
throw new BadRequestException('JUNIOR.NOT_BELONG_TO_GUARDIAN');
}
this.logger.log(`Getting weekly summary for junior ${juniorId}`);
return this.cardService.getWeeklySummary(juniorId, startDate, endDate);
}
async getJuniorHome(juniorId: string, userId: string, size: number): Promise<JuniorHomeResponseDto> {
this.logger.log(`Getting home for junior ${juniorId}`);
// Check if user is the junior themselves or their guardian
let junior: Junior | null;
if (juniorId === userId) {
// User is the junior accessing their own home
junior = await this.findJuniorById(juniorId, false);
} else {
// User might be the guardian accessing junior's home
junior = await this.findJuniorById(juniorId, false, userId);
}
if (!junior) {
throw new BadRequestException('JUNIOR.NOT_FOUND');
}
const card = junior.customer?.cards?.[0];
const availableBalance = card ? Math.min(card.limit, card.account.balance) : 0;
const recentTransfers = await this.transactionService.getChildTransfers(juniorId, 1, size);
return new JuniorHomeResponseDto(availableBalance, recentTransfers);
}
async getJuniorTransfers(
juniorId: string,
userId: string,
page: number,
size: number,
): Promise<PagedChildTransfersResponseDto> {
this.logger.log(`Getting transfers for junior ${juniorId}`);
// Check if user is the junior themselves or their guardian
let junior: Junior | null;
if (juniorId === userId) {
// User is the junior accessing their own transfers
junior = await this.findJuniorById(juniorId, false);
} else {
// User might be the guardian accessing junior's transfers
junior = await this.findJuniorById(juniorId, false, userId);
}
if (!junior) {
throw new BadRequestException('JUNIOR.NOT_FOUND');
}
return this.transactionService.getChildTransfersPaginated(juniorId, page, size);
}
async getSpendingHistory(juniorId: string, userId: string, startUtc: Date, endUtc: Date) {
this.logger.log(`Getting spending history for junior ${juniorId}`);
// Check if user is the junior themselves or their guardian
let junior: Junior | null;
if (juniorId === userId) {
junior = await this.findJuniorById(juniorId, false);
} else {
junior = await this.findJuniorById(juniorId, false, userId);
}
if (!junior) {
throw new BadRequestException('JUNIOR.NOT_FOUND');
}
return this.transactionService.getChildSpendingHistory(juniorId, startUtc, endUtc);
}
async getTransactionDetail(juniorId: string, userId: string, transactionId: string) {
this.logger.log(`Getting transaction detail ${transactionId} for junior ${juniorId}`);
// Check if user is the junior themselves or their guardian
let junior: Junior | null;
if (juniorId === userId) {
junior = await this.findJuniorById(juniorId, false);
} else {
junior = await this.findJuniorById(juniorId, false, userId);
}
if (!junior) {
throw new BadRequestException('JUNIOR.NOT_FOUND');
}
return this.transactionService.getTransactionDetail(transactionId, juniorId);
}
private async prepareJuniorImages(juniors: Junior[]) {
this.logger.log(`Preparing junior images`);
await Promise.all(

View File

@ -1,6 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { Gender } from '~/customer/enums';
export class UpdateUserRequestDto {
@ApiProperty({ example: 'John' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'user.firstName' }) })
@ -14,8 +15,23 @@ export class UpdateUserRequestDto {
@IsOptional()
lastName!: string;
@ApiPropertyOptional({ example: 'child@example.com' })
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'user.email' }) })
@IsOptional()
email!: string;
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'user.profilePictureId' }) })
@IsOptional()
profilePictureId!: string;
@ApiPropertyOptional({ enum: Gender })
@IsEnum(Gender, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.gender' }) })
@IsOptional()
gender!: Gender;
@ApiPropertyOptional({ example: '2020-01-01' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) })
@IsOptional()
dateOfBirth!: Date;
}

View File

@ -28,7 +28,7 @@ export class User extends BaseEntity {
@Column('varchar', { length: 255, name: 'last_name', nullable: false })
lastName!: string;
@Column('varchar', { length: 255, name: 'email', nullable: true })
@Column('varchar', { length: 255, name: 'email', nullable: true, unique: true })
email!: string;
@Column('varchar', { length: 255, name: 'phone_number', nullable: true })

View File

@ -191,16 +191,38 @@ export class UserService {
async updateUser(userId: string, data: UpdateUserRequestDto) {
await this.validateProfilePictureId(data.profilePictureId, userId);
if (data.email) {
const userWithEmail = await this.findUser({ email: data.email });
if (userWithEmail && userWithEmail.id !== userId) {
this.logger.error(`Email ${data.email} is already taken by another user`);
throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN');
}
}
this.logger.log(`Updating user ${userId} with data ${JSON.stringify(data)}`);
const { affected } = await this.userRepository.update(userId, data);
const { gender, dateOfBirth, ...userData } = data;
const { affected } = await this.userRepository.update(userId, userData);
if (affected === 0) {
this.logger.error(`User with id ${userId} not found`);
throw new BadRequestException('USER.NOT_FOUND');
}
if (gender !== undefined || dateOfBirth !== undefined) {
const customerData: Partial<{ gender: typeof gender; dateOfBirth: Date }> = {};
if (gender !== undefined) {
customerData.gender = gender;
}
if (dateOfBirth !== undefined) {
customerData.dateOfBirth = dateOfBirth;
}
await this.customerService.updateCustomer(userId, customerData);
}
}
async updateUserEmail(userId: string, email: string) {
const userWithEmail = await this.findUser({ email, isEmailVerified: true });
const userWithEmail = await this.findUser({ email });
if (userWithEmail) {
if (userWithEmail.id === userId) {