Compare commits

...

16 Commits

Author SHA1 Message Date
bf43e62b17 feat: handle card status changed webhook 2025-07-21 15:30:55 +03:00
5a780eeb17 feat/working on update card control 2025-07-14 11:57:51 +03:00
038b8ef6e3 feat: finish working on account transaction webhook 2025-07-09 13:31:08 +03:00
3b3f8c0104 fix: remove host from request 2025-07-07 16:34:45 +03:00
2770cf8774 fix:fix card migration 2025-07-07 12:06:01 +03:00
bea3ccfbbc Merge branch 'waiting-list' into feat/neoleap-integration 2025-07-06 16:45:37 +03:00
492e538eb8 feat: send request via gateway 2025-07-06 16:44:23 +03:00
d3057beb54 feat: add transaction, card , and account entities 2025-07-02 18:42:38 +03:00
4cbbfd8136 Merge branch 'waiting-list' into feat/neoleap-integration 2025-06-11 11:15:10 +03:00
d1a6d3e715 feat: add test controller for integartion 2025-06-04 10:04:45 +03:00
1ea1f42169 feat: finish create and inquire application api and handle response and errors 2025-06-03 14:51:36 +03:00
d4fe3b3fc3 feat: finish working on mocking inquire application api 2025-05-26 16:34:09 +03:00
9aa6c487ed Merge branch 'waiting-list' into feat/neoleap-integration 2025-05-26 12:11:53 +03:00
a358cd2e7a feat: add neoleap service and mock create application api 2025-05-26 12:04:00 +03:00
641a665beb Merge branch 'waiting-list' into feat/neoleap-integration 2025-05-21 09:59:18 +03:00
881d88c8d8 feat: add customer details to customer entity 2025-05-19 14:16:18 +03:00
68 changed files with 3574 additions and 15 deletions

3
.gitignore vendored
View File

@ -53,4 +53,5 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
zod-certs
zod-certs

View File

@ -11,6 +11,7 @@
"exclude": "**/*.md"
},
{ "include": "common/modules/**/templates/**/*", "watchAssets": true },
{ "include": "common/modules/neoleap/zod-certs" },
"i18n",
"files"
]

7
package-lock.json generated
View File

@ -33,6 +33,7 @@
"cacheable": "^1.8.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"decimal.js": "^10.6.0",
"firebase-admin": "^13.0.2",
"google-libphonenumber": "^3.2.39",
"handlebars": "^4.7.8",
@ -5167,6 +5168,12 @@
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/dedent": {
"version": "1.5.3",
"dev": true,

View File

@ -51,6 +51,7 @@
"cacheable": "^1.8.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"decimal.js": "^10.6.0",
"firebase-admin": "^13.0.2",
"google-libphonenumber": "^3.2.39",
"handlebars": "^4.7.8",

View File

@ -12,6 +12,7 @@ import { AllowanceModule } from './allowance/allowance.module';
import { AuthModule } from './auth/auth.module';
import { CacheModule } from './common/modules/cache/cache.module';
import { LookupModule } from './common/modules/lookup/lookup.module';
import { NeoLeapModule } from './common/modules/neoleap/neoleap.module';
import { NotificationModule } from './common/modules/notification/notification.module';
import { OtpModule } from './common/modules/otp/otp.module';
import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
@ -30,6 +31,7 @@ import { MoneyRequestModule } from './money-request/money-request.module';
import { SavingGoalsModule } from './saving-goals/saving-goals.module';
import { TaskModule } from './task/task.module';
import { UserModule } from './user/user.module';
import { CardModule } from './card/card.module';
@Module({
controllers: [],
@ -80,6 +82,8 @@ import { UserModule } from './user/user.module';
UserModule,
CronModule,
NeoLeapModule,
CardModule,
],
providers: [
// Global Pipes

25
src/card/card.module.ts Normal file
View File

@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Card } from './entities';
import { Account } from './entities/account.entity';
import { Transaction } from './entities/transaction.entity';
import { CardRepository } from './repositories';
import { AccountRepository } from './repositories/account.repository';
import { TransactionRepository } from './repositories/transaction.repository';
import { CardService } from './services';
import { AccountService } from './services/account.service';
import { TransactionService } from './services/transaction.service';
@Module({
imports: [TypeOrmModule.forFeature([Card, Account, Transaction])],
providers: [
CardService,
CardRepository,
TransactionService,
TransactionRepository,
AccountService,
AccountRepository,
],
exports: [CardService, TransactionService],
})
export class CardModule {}

View File

@ -0,0 +1,31 @@
import { Column, CreateDateColumn, Entity, Index, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { Card } from './card.entity';
import { Transaction } from './transaction.entity';
@Entity('accounts')
export class Account {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column('varchar', { length: 255, nullable: false, unique: true, name: 'account_reference' })
@Index({ unique: true })
accountReference!: string;
@Column('varchar', { length: 255, nullable: false, name: 'currency' })
currency!: string;
@Column('decimal', { precision: 10, scale: 2, default: 0.0, name: 'balance' })
balance!: number;
@OneToMany(() => Card, (card) => card.account, { cascade: true })
cards!: Card[];
@OneToMany(() => Transaction, (transaction) => transaction.account, { cascade: true })
transactions!: Transaction[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone' })
updatedAt!: Date;
}

View File

@ -0,0 +1,85 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Customer } from '~/customer/entities';
import { CardColors, CardIssuers, CardScheme, CardStatus, CardStatusDescription, CustomerType } from '../enums';
import { Account } from './account.entity';
import { Transaction } from './transaction.entity';
@Entity('cards')
export class Card {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index({ unique: true })
@Column({ name: 'card_reference', nullable: false, type: 'varchar' })
cardReference!: string;
@Column({ length: 6, name: 'first_six_digits', nullable: false, type: 'varchar' })
firstSixDigits!: string;
@Column({ length: 4, name: 'last_four_digits', nullable: false, type: 'varchar' })
lastFourDigits!: string;
@Column({ type: 'varchar', nullable: false })
expiry!: string;
@Column({ type: 'varchar', nullable: false, name: 'customer_type' })
customerType!: CustomerType;
@Column({ type: 'varchar', nullable: false, default: CardColors.BLUE })
color!: CardColors;
@Column({ type: 'varchar', nullable: false, default: CardStatus.PENDING })
status!: CardStatus;
@Column({ type: 'varchar', nullable: false, default: CardStatusDescription.PENDING_ACTIVATION })
statusDescription!: CardStatusDescription;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0.0, name: 'limit' })
limit!: number;
@Column({ type: 'varchar', nullable: false, default: CardScheme.VISA })
scheme!: CardScheme;
@Column({ type: 'varchar', nullable: false })
issuer!: CardIssuers;
@Column({ type: 'uuid', name: 'customer_id', nullable: false })
customerId!: string;
@Column({ type: 'uuid', name: 'parent_id', nullable: true })
parentId?: string;
@Column({ type: 'uuid', name: 'account_id', nullable: false })
accountId!: string;
@ManyToOne(() => Customer, (customer) => customer.childCards)
@JoinColumn({ name: 'parent_id' })
parentCustomer?: Customer;
@ManyToOne(() => Customer, (customer) => customer.cards, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'customer_id' })
customer!: Customer;
@ManyToOne(() => Account, (account) => account.cards, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'account_id' })
account!: Account;
@OneToMany(() => Transaction, (transaction) => transaction.card, { cascade: true })
transactions!: Transaction[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamp with time zone', name: 'updated_at' })
updatedAt!: Date;
}

View File

@ -0,0 +1 @@
export * from './card.entity';

View File

@ -0,0 +1,69 @@
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { TransactionScope, TransactionType } from '../enums';
import { Account } from './account.entity';
import { Card } from './card.entity';
@Entity('transactions')
export class Transaction {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'transaction_scope', type: 'varchar', nullable: false })
transactionScope!: TransactionScope;
@Column({ name: 'transaction_type', type: 'varchar', default: TransactionType.EXTERNAL })
transactionType!: TransactionType;
@Column({ name: 'card_reference', nullable: true, type: 'varchar' })
cardReference!: string;
@Column({ name: 'account_reference', nullable: true, type: 'varchar' })
accountReference!: string;
@Column({ name: 'transaction_id', unique: true, nullable: true, type: 'varchar' })
transactionId!: string;
@Column({ name: 'card_masked_number', nullable: true, type: 'varchar' })
cardMaskedNumber!: string;
@Column({ type: 'timestamp with time zone', name: 'transaction_date', nullable: true })
transactionDate!: Date;
@Column({ name: 'rrn', nullable: true, type: 'varchar' })
rrn!: string;
@Column({ type: 'decimal', precision: 12, scale: 2, name: 'transaction_amount' })
transactionAmount!: number;
@Column({ type: 'varchar', name: 'transaction_currency' })
transactionCurrency!: string;
@Column({ type: 'decimal', name: 'billing_amount', precision: 12, scale: 2 })
billingAmount!: number;
@Column({ type: 'decimal', name: 'settlement_amount', precision: 12, scale: 2 })
settlementAmount!: number;
@Column({ type: 'decimal', name: 'fees', precision: 12, scale: 2 })
fees!: number;
@Column({ type: 'decimal', name: 'vat_on_fees', precision: 12, scale: 2, default: 0.0 })
vatOnFees!: number;
@Column({ name: 'card_id', type: 'uuid', nullable: true })
cardId!: string;
@Column({ name: 'account_id', type: 'uuid', nullable: true })
accountId!: string;
@ManyToOne(() => Card, (card) => card.transactions, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'card_id' })
card!: Card;
@ManyToOne(() => Account, (account) => account.transactions, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'account_id' })
account!: Account;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date;
}

View File

@ -0,0 +1,4 @@
export enum CardColors {
RED = 'RED',
BLUE = 'BLUE',
}

View File

@ -0,0 +1,3 @@
export enum CardIssuers {
NEOLEAP = 'NEOLEAP',
}

View File

@ -0,0 +1,4 @@
export enum CardScheme {
VISA = 'VISA',
MASTERCARD = 'MASTERCARD',
}

View File

@ -0,0 +1,68 @@
/**
* import { CardStatus, CardStatusDescription } from '../enums';
export const CardStatusMapper: Record<string, { description: CardStatusDescription; status: CardStatus }> = {
//ACTIVE
'00': { description: 'NORMAL', status: CardStatus.ACTIVE },
//PENDING
'02': { description: 'NOT_YET_ISSUED', status: CardStatus.PENDING },
'20': { description: 'PENDING_ISSUANCE', status: CardStatus.PENDING },
'21': { description: 'CARD_EXTRACTED', status: CardStatus.PENDING },
'22': { description: 'EXTRACTION_FAILED', status: CardStatus.PENDING },
'23': { description: 'FAILED_PRINTING_BULK', status: CardStatus.PENDING },
'24': { description: 'FAILED_PRINTING_INST', status: CardStatus.PENDING },
'30': { description: 'PENDING_ACTIVATION', status: CardStatus.PENDING },
'27': { description: 'PENDING_PIN', status: CardStatus.PENDING },
'16': { description: 'PREPARE_TO_CLOSE', status: CardStatus.PENDING },
//BLOCKED
'01': { description: 'PIN_TRIES_EXCEEDED', status: CardStatus.BLOCKED },
'03': { description: 'CARD_EXPIRED', status: CardStatus.BLOCKED },
'04': { description: 'LOST', status: CardStatus.BLOCKED },
'05': { description: 'STOLEN', status: CardStatus.BLOCKED },
'06': { description: 'CUSTOMER_CLOSE', status: CardStatus.BLOCKED },
'07': { description: 'BANK_CANCELLED', status: CardStatus.BLOCKED },
'08': { description: 'FRAUD', status: CardStatus.BLOCKED },
'09': { description: 'DAMAGED', status: CardStatus.BLOCKED },
'50': { description: 'SAFE_BLOCK', status: CardStatus.BLOCKED },
'51': { description: 'TEMPORARY_BLOCK', status: CardStatus.BLOCKED },
'52': { description: 'RISK_BLOCK', status: CardStatus.BLOCKED },
'53': { description: 'OVERDRAFT', status: CardStatus.BLOCKED },
'54': { description: 'BLOCKED_FOR_FEES', status: CardStatus.BLOCKED },
'67': { description: 'CLOSED_CUSTOMER_DEAD', status: CardStatus.BLOCKED },
'75': { description: 'RETURN_CARD', status: CardStatus.BLOCKED },
//Fallback
'99': { description: 'UNKNOWN', status: CardStatus.PENDING },
};
*/
export enum CardStatusDescription {
NORMAL = 'NORMAL',
NOT_YET_ISSUED = 'NOT_YET_ISSUED',
PENDING_ISSUANCE = 'PENDING_ISSUANCE',
CARD_EXTRACTED = 'CARD_EXTRACTED',
EXTRACTION_FAILED = 'EXTRACTION_FAILED',
FAILED_PRINTING_BULK = 'FAILED_PRINTING_BULK',
FAILED_PRINTING_INST = 'FAILED_PRINTING_INST',
PENDING_ACTIVATION = 'PENDING_ACTIVATION',
PENDING_PIN = 'PENDING_PIN',
PREPARE_TO_CLOSE = 'PREPARE_TO_CLOSE',
PIN_TRIES_EXCEEDED = 'PIN_TRIES_EXCEEDED',
CARD_EXPIRED = 'CARD_EXPIRED',
LOST = 'LOST',
STOLEN = 'STOLEN',
CUSTOMER_CLOSE = 'CUSTOMER_CLOSE',
BANK_CANCELLED = 'BANK_CANCELLED',
FRAUD = 'FRAUD',
DAMAGED = 'DAMAGED',
SAFE_BLOCK = 'SAFE_BLOCK',
TEMPORARY_BLOCK = 'TEMPORARY_BLOCK',
RISK_BLOCK = 'RISK_BLOCK',
OVERDRAFT = 'OVERDRAFT',
BLOCKED_FOR_FEES = 'BLOCKED_FOR_FEES',
CLOSED_CUSTOMER_DEAD = 'CLOSED_CUSTOMER_DEAD',
RETURN_CARD = 'RETURN_CARD',
UNKNOWN = 'UNKNOWN',
}

View File

@ -0,0 +1,6 @@
export enum CardStatus {
ACTIVE = 'ACTIVE',
CANCELED = 'CANCELED',
BLOCKED = 'BLOCKED',
PENDING = 'PENDING',
}

View File

@ -0,0 +1,4 @@
export enum CustomerType {
PARENT = 'PARENT',
CHILD = 'CHILD',
}

8
src/card/enums/index.ts Normal file
View File

@ -0,0 +1,8 @@
export * from './card-colors.enum';
export * from './card-issuers.enum';
export * from './card-scheme.enum';
export * from './card-status-description.enum';
export * from './card-status.enum';
export * from './customer-type.enum';
export * from './transaction-scope.enum';
export * from './transaction-type.enum';

View File

@ -0,0 +1,4 @@
export enum TransactionScope {
CARD = 'CARD',
ACCOUNT = 'ACCOUNT',
}

View File

@ -0,0 +1,4 @@
export enum TransactionType {
INTERNAL = 'INTERNAL',
EXTERNAL = 'EXTERNAL',
}

View File

@ -0,0 +1,109 @@
import { UserLocale } from '~/core/enums';
import { CardStatusDescription } from '../enums';
export const CardStatusMapper: Record<CardStatusDescription, { [key in UserLocale]: { description: string } }> = {
[CardStatusDescription.NORMAL]: {
[UserLocale.ENGLISH]: { description: 'The card is active' },
[UserLocale.ARABIC]: { description: 'البطاقة نشطة' },
},
[CardStatusDescription.NOT_YET_ISSUED]: {
[UserLocale.ENGLISH]: { description: 'The card is not yet issued' },
[UserLocale.ARABIC]: { description: 'البطاقة لم تصدر بعد' },
},
[CardStatusDescription.PENDING_ISSUANCE]: {
[UserLocale.ENGLISH]: { description: 'The card is pending issuance' },
[UserLocale.ARABIC]: { description: 'البطاقة قيد الإصدار' },
},
[CardStatusDescription.CARD_EXTRACTED]: {
[UserLocale.ENGLISH]: { description: 'The card has been extracted' },
[UserLocale.ARABIC]: { description: 'تم استخراج البطاقة' },
},
[CardStatusDescription.EXTRACTION_FAILED]: {
[UserLocale.ENGLISH]: { description: 'The card extraction has failed' },
[UserLocale.ARABIC]: { description: 'فشل استخراج البطاقة' },
},
[CardStatusDescription.FAILED_PRINTING_BULK]: {
[UserLocale.ENGLISH]: { description: 'The card printing in bulk has failed' },
[UserLocale.ARABIC]: { description: 'فشل الطباعة بالجملة للبطاقة' },
},
[CardStatusDescription.FAILED_PRINTING_INST]: {
[UserLocale.ENGLISH]: { description: 'The card printing in institution has failed' },
[UserLocale.ARABIC]: { description: 'فشل الطباعة في المؤسسة للبطاقة' },
},
[CardStatusDescription.PENDING_ACTIVATION]: {
[UserLocale.ENGLISH]: { description: 'The card is pending activation' },
[UserLocale.ARABIC]: { description: 'البطاقة قيد التفعيل' },
},
[CardStatusDescription.PENDING_PIN]: {
[UserLocale.ENGLISH]: { description: 'The card is pending PIN' },
[UserLocale.ARABIC]: { description: 'البطاقة قيد الانتظار لرقم التعريف الشخصي' },
},
[CardStatusDescription.PREPARE_TO_CLOSE]: {
[UserLocale.ENGLISH]: { description: 'The card is being prepared for closure' },
[UserLocale.ARABIC]: { description: 'البطاقة قيد التحضير للإغلاق' },
},
[CardStatusDescription.PIN_TRIES_EXCEEDED]: {
[UserLocale.ENGLISH]: { description: 'The card PIN tries have been exceeded' },
[UserLocale.ARABIC]: { description: 'تم تجاوز محاولات رقم التعريف الشخصي للبطاقة' },
},
[CardStatusDescription.CARD_EXPIRED]: {
[UserLocale.ENGLISH]: { description: 'The card has expired' },
[UserLocale.ARABIC]: { description: 'انتهت صلاحية البطاقة' },
},
[CardStatusDescription.LOST]: {
[UserLocale.ENGLISH]: { description: 'The card is lost' },
[UserLocale.ARABIC]: { description: 'البطاقة ضائعة' },
},
[CardStatusDescription.STOLEN]: {
[UserLocale.ENGLISH]: { description: 'The card is stolen' },
[UserLocale.ARABIC]: { description: 'البطاقة مسروقة' },
},
[CardStatusDescription.CUSTOMER_CLOSE]: {
[UserLocale.ENGLISH]: { description: 'The card is being closed by the customer' },
[UserLocale.ARABIC]: { description: 'البطاقة قيد الإغلاق من قبل العميل' },
},
[CardStatusDescription.BANK_CANCELLED]: {
[UserLocale.ENGLISH]: { description: 'The card has been cancelled by the bank' },
[UserLocale.ARABIC]: { description: 'البطاقة ألغيت من قبل البنك' },
},
[CardStatusDescription.FRAUD]: {
[UserLocale.ENGLISH]: { description: 'Fraud' },
[UserLocale.ARABIC]: { description: 'احتيال' },
},
[CardStatusDescription.DAMAGED]: {
[UserLocale.ENGLISH]: { description: 'The card is damaged' },
[UserLocale.ARABIC]: { description: 'البطاقة تالفة' },
},
[CardStatusDescription.SAFE_BLOCK]: {
[UserLocale.ENGLISH]: { description: 'The card is in a safe block' },
[UserLocale.ARABIC]: { description: 'البطاقة في حظر آمن' },
},
[CardStatusDescription.TEMPORARY_BLOCK]: {
[UserLocale.ENGLISH]: { description: 'The card is in a temporary block' },
[UserLocale.ARABIC]: { description: 'البطاقة في حظر مؤقت' },
},
[CardStatusDescription.RISK_BLOCK]: {
[UserLocale.ENGLISH]: { description: 'The card is in a risk block' },
[UserLocale.ARABIC]: { description: 'البطاقة في حظر المخاطر' },
},
[CardStatusDescription.OVERDRAFT]: {
[UserLocale.ENGLISH]: { description: 'The card is in overdraft' },
[UserLocale.ARABIC]: { description: 'البطاقة في السحب على المكشوف' },
},
[CardStatusDescription.BLOCKED_FOR_FEES]: {
[UserLocale.ENGLISH]: { description: 'The card is blocked for fees' },
[UserLocale.ARABIC]: { description: 'البطاقة محظورة للرسوم' },
},
[CardStatusDescription.CLOSED_CUSTOMER_DEAD]: {
[UserLocale.ENGLISH]: { description: 'The card is closed because the customer is dead' },
[UserLocale.ARABIC]: { description: 'البطاقة مغلقة لأن العميل متوفى' },
},
[CardStatusDescription.RETURN_CARD]: {
[UserLocale.ENGLISH]: { description: 'The card is being returned' },
[UserLocale.ARABIC]: { description: 'البطاقة قيد الإرجاع' },
},
[CardStatusDescription.UNKNOWN]: {
[UserLocale.ENGLISH]: { description: 'The card status is unknown' },
[UserLocale.ARABIC]: { description: 'حالة البطاقة غير معروفة' },
},
};

View File

@ -0,0 +1,37 @@
import { CardStatus, CardStatusDescription } from '../enums';
export const CardStatusMapper: Record<string, { description: CardStatusDescription; status: CardStatus }> = {
//ACTIVE
'00': { description: CardStatusDescription.NORMAL, status: CardStatus.ACTIVE },
//PENDING
'02': { description: CardStatusDescription.NOT_YET_ISSUED, status: CardStatus.PENDING },
'20': { description: CardStatusDescription.PENDING_ISSUANCE, status: CardStatus.PENDING },
'21': { description: CardStatusDescription.CARD_EXTRACTED, status: CardStatus.PENDING },
'22': { description: CardStatusDescription.EXTRACTION_FAILED, status: CardStatus.PENDING },
'23': { description: CardStatusDescription.FAILED_PRINTING_BULK, status: CardStatus.PENDING },
'24': { description: CardStatusDescription.FAILED_PRINTING_INST, status: CardStatus.PENDING },
'30': { description: CardStatusDescription.PENDING_ACTIVATION, status: CardStatus.PENDING },
'27': { description: CardStatusDescription.PENDING_PIN, status: CardStatus.PENDING },
'16': { description: CardStatusDescription.PREPARE_TO_CLOSE, status: CardStatus.PENDING },
//BLOCKED
'01': { description: CardStatusDescription.PIN_TRIES_EXCEEDED, status: CardStatus.BLOCKED },
'03': { description: CardStatusDescription.CARD_EXPIRED, status: CardStatus.BLOCKED },
'04': { description: CardStatusDescription.LOST, status: CardStatus.BLOCKED },
'05': { description: CardStatusDescription.STOLEN, status: CardStatus.BLOCKED },
'06': { description: CardStatusDescription.CUSTOMER_CLOSE, status: CardStatus.BLOCKED },
'07': { description: CardStatusDescription.BANK_CANCELLED, status: CardStatus.BLOCKED },
'08': { description: CardStatusDescription.FRAUD, status: CardStatus.BLOCKED },
'09': { description: CardStatusDescription.DAMAGED, status: CardStatus.BLOCKED },
'50': { description: CardStatusDescription.SAFE_BLOCK, status: CardStatus.BLOCKED },
'51': { description: CardStatusDescription.TEMPORARY_BLOCK, status: CardStatus.BLOCKED },
'52': { description: CardStatusDescription.RISK_BLOCK, status: CardStatus.BLOCKED },
'53': { description: CardStatusDescription.OVERDRAFT, status: CardStatus.BLOCKED },
'54': { description: CardStatusDescription.BLOCKED_FOR_FEES, status: CardStatus.BLOCKED },
'67': { description: CardStatusDescription.CLOSED_CUSTOMER_DEAD, status: CardStatus.BLOCKED },
'75': { description: CardStatusDescription.RETURN_CARD, status: CardStatus.BLOCKED },
//Fallback
'99': { description: CardStatusDescription.UNKNOWN, status: CardStatus.PENDING },
};

View File

@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Account } from '../entities/account.entity';
@Injectable()
export class AccountRepository {
constructor(@InjectRepository(Account) private readonly accountRepository: Repository<Account>) {}
createAccount(accountId: string): Promise<Account> {
return this.accountRepository.save(
this.accountRepository.create({
accountReference: accountId,
balance: 0,
currency: '682',
}),
);
}
getAccountByReferenceNumber(accountReference: string): Promise<Account | null> {
return this.accountRepository.findOne({
where: { accountReference },
relations: ['cards'],
});
}
topUpAccountBalance(accountReference: string, amount: number) {
return this.accountRepository.increment({ accountReference }, 'balance', amount);
}
decreaseAccountBalance(accountReference: string, amount: number) {
return this.accountRepository.decrement({ accountReference }, 'balance', amount);
}
}

View File

@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response';
import { Card } from '../entities';
import { CardColors, CardIssuers, CardScheme, CardStatus, CardStatusDescription, CustomerType } from '../enums';
@Injectable()
export class CardRepository {
constructor(@InjectRepository(Card) private readonly cardRepository: Repository<Card>) {}
createCard(customerId: string, accountId: string, card: CreateApplicationResponse): Promise<Card> {
return this.cardRepository.save(
this.cardRepository.create({
customerId: customerId,
expiry: card.expiryDate,
cardReference: card.cardId,
customerType: CustomerType.PARENT,
firstSixDigits: card.firstSixDigits,
lastFourDigits: card.lastFourDigits,
color: CardColors.BLUE,
scheme: CardScheme.VISA,
issuer: CardIssuers.NEOLEAP,
accountId: accountId,
}),
);
}
getCardById(id: string): Promise<Card | null> {
return this.cardRepository.findOne({ where: { id } });
}
getCardByReferenceNumber(referenceNumber: string): Promise<Card | null> {
return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] });
}
getActiveCardForCustomer(customerId: string): Promise<Card | null> {
return this.cardRepository.findOne({
where: { customerId, status: CardStatus.ACTIVE },
});
}
updateCardStatus(id: string, status: CardStatus, statusDescription: CardStatusDescription) {
return this.cardRepository.update(id, {
status: status,
statusDescription: statusDescription,
});
}
}

View File

@ -0,0 +1 @@
export * from './card.repository';

View File

@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import moment from 'moment';
import { Repository } from 'typeorm';
import {
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
} from '~/common/modules/neoleap/dtos/requests';
import { Card } from '../entities';
import { Account } from '../entities/account.entity';
import { Transaction } from '../entities/transaction.entity';
import { TransactionScope, TransactionType } from '../enums';
@Injectable()
export class TransactionRepository {
constructor(@InjectRepository(Transaction) private transactionRepository: Repository<Transaction>) {}
createCardTransaction(card: Card, transactionData: CardTransactionWebhookRequest): Promise<Transaction> {
return this.transactionRepository.save(
this.transactionRepository.create({
transactionId: transactionData.transactionId,
cardReference: transactionData.cardId,
transactionAmount: transactionData.transactionAmount,
transactionCurrency: transactionData.transactionCurrency,
billingAmount: transactionData.billingAmount,
settlementAmount: transactionData.settlementAmount,
transactionDate: moment(transactionData.date + transactionData.time, 'YYYYMMDDHHmmss').toDate(),
rrn: transactionData.rrn,
cardMaskedNumber: transactionData.cardMaskedNumber,
fees: transactionData.fees,
cardId: card.id,
accountId: card.account!.id,
transactionType: TransactionType.EXTERNAL,
accountReference: card.account!.accountReference,
transactionScope: TransactionScope.CARD,
vatOnFees: transactionData.vatOnFees,
}),
);
}
createAccountTransaction(account: Account, transactionData: AccountTransactionWebhookRequest): Promise<Transaction> {
return this.transactionRepository.save(
this.transactionRepository.create({
transactionId: transactionData.transactionId,
transactionAmount: transactionData.amount,
transactionCurrency: transactionData.currency,
billingAmount: 0,
settlementAmount: 0,
transactionDate: moment(transactionData.date + transactionData.time, 'YYYYMMDDHHmmss').toDate(),
fees: 0,
accountReference: account.accountReference,
accountId: account.id,
transactionType: TransactionType.EXTERNAL,
transactionScope: TransactionScope.ACCOUNT,
vatOnFees: 0,
}),
);
}
findTransactionByReference(transactionId: string, accountReference: string): Promise<Transaction | null> {
return this.transactionRepository.findOne({
where: { transactionId, accountReference },
});
}
}

View File

@ -0,0 +1,38 @@
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { Account } from '../entities/account.entity';
import { AccountRepository } from '../repositories/account.repository';
@Injectable()
export class AccountService {
constructor(private readonly accountRepository: AccountRepository) {}
createAccount(accountId: string): Promise<Account> {
return this.accountRepository.createAccount(accountId);
}
async getAccountByReferenceNumber(accountReference: string): Promise<Account> {
const account = await this.accountRepository.getAccountByReferenceNumber(accountReference);
if (!account) {
throw new UnprocessableEntityException('ACCOUNT.NOT_FOUND');
}
return account;
}
async creditAccountBalance(accountReference: string, amount: number) {
return this.accountRepository.topUpAccountBalance(accountReference, amount);
}
async decreaseAccountBalance(accountReference: string, amount: number) {
const account = await this.getAccountByReferenceNumber(accountReference);
/**
* While there is no need to check for insufficient balance because this is a webhook handler,
* I just added this check to ensure we don't have corruption in our data especially if this service is used elsewhere.
*/
if (account.balance < amount) {
throw new UnprocessableEntityException('ACCOUNT.INSUFFICIENT_BALANCE');
}
return this.accountRepository.decreaseAccountBalance(accountReference, amount);
}
}

View File

@ -0,0 +1,54 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Transactional } from 'typeorm-transactional';
import { AccountCardStatusChangedWebhookRequest } from '~/common/modules/neoleap/dtos/requests';
import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response';
import { Card } from '../entities';
import { CardStatusMapper } from '../mappers/card-status.mapper';
import { CardRepository } from '../repositories';
import { AccountService } from './account.service';
@Injectable()
export class CardService {
constructor(private readonly cardRepository: CardRepository, private readonly accountService: AccountService) {}
@Transactional()
async createCard(customerId: string, cardData: CreateApplicationResponse): Promise<Card> {
const account = await this.accountService.createAccount(cardData.accountId);
return this.cardRepository.createCard(customerId, account.id, cardData);
}
async getCardById(id: string): Promise<Card> {
const card = await this.cardRepository.getCardById(id);
if (!card) {
throw new BadRequestException('CARD.NOT_FOUND');
}
return card;
}
async getCardByReferenceNumber(referenceNumber: string): Promise<Card> {
const card = await this.cardRepository.getCardByReferenceNumber(referenceNumber);
if (!card) {
throw new BadRequestException('CARD.NOT_FOUND');
}
return card;
}
async getActiveCardForCustomer(customerId: string): Promise<Card> {
const card = await this.cardRepository.getActiveCardForCustomer(customerId);
if (!card) {
throw new BadRequestException('CARD.NOT_FOUND');
}
return card;
}
async updateCardStatus(body: AccountCardStatusChangedWebhookRequest) {
const card = await this.getCardByReferenceNumber(body.cardId);
const { description, status } = CardStatusMapper[body.newStatus] || CardStatusMapper['99'];
return this.cardRepository.updateCardStatus(card.id, status, description);
}
}

View File

@ -0,0 +1 @@
export * from './card.service';

View File

@ -0,0 +1,62 @@
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import Decimal from 'decimal.js';
import { Transactional } from 'typeorm-transactional';
import {
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
} from '~/common/modules/neoleap/dtos/requests';
import { Transaction } from '../entities/transaction.entity';
import { TransactionRepository } from '../repositories/transaction.repository';
import { AccountService } from './account.service';
import { CardService } from './card.service';
@Injectable()
export class TransactionService {
constructor(
private readonly transactionRepository: TransactionRepository,
private readonly cardService: CardService,
private readonly accountService: AccountService,
) {}
@Transactional()
async createCardTransaction(body: CardTransactionWebhookRequest) {
const card = await this.cardService.getCardByReferenceNumber(body.cardId);
const existingTransaction = await this.findExistingTransaction(body.transactionId, card.account.accountReference);
if (existingTransaction) {
throw new UnprocessableEntityException('TRANSACTION.ALREADY_EXISTS');
}
const transaction = await this.transactionRepository.createCardTransaction(card, body);
const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees);
await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber());
return transaction;
}
@Transactional()
async createAccountTransaction(body: AccountTransactionWebhookRequest) {
const account = await this.accountService.getAccountByReferenceNumber(body.accountId);
const existingTransaction = await this.findExistingTransaction(body.transactionId, account.accountReference);
if (existingTransaction) {
throw new UnprocessableEntityException('TRANSACTION.ALREADY_EXISTS');
}
const transaction = await this.transactionRepository.createAccountTransaction(account, body);
await this.accountService.creditAccountBalance(account.accountReference, body.amount);
return transaction;
}
private async findExistingTransaction(transactionId: string, accountReference: string): Promise<Transaction | null> {
const existingTransaction = await this.transactionRepository.findTransactionByReference(
transactionId,
accountReference,
);
return existingTransaction;
}
}

View File

@ -0,0 +1,750 @@
export const CREATE_APPLICATION_MOCK = {
ResponseHeader: {
Version: '1.0.0',
MsgUid: 'adaa1893-9f95-48a8-b7a1-0422bcf629b5',
Source: 'ZOD',
ServiceId: 'CreateNewApplication',
ReqDateTime: '2025-06-03T07:32:16.304Z',
RspDateTime: '2025-06-03T08:21:15.662',
ResponseCode: '000',
ResponseType: 'Success',
ProcessingTime: 1665,
EncryptionKey: null,
ResponseDescription: 'Operation Successful',
LocalizedResponseDescription: null,
CustomerSpecificResponseDescriptionList: null,
HeaderUserDataList: null,
},
CreateNewApplicationResponseDetails: {
InstitutionCode: '1100',
ApplicationTypeDetails: {
TypeCode: '01',
Description: 'Normal Primary',
Additional: false,
Corporate: false,
UserData: null,
},
ApplicationDetails: {
cif: null,
ApplicationNumber: '3300000000073',
ExternalApplicationNumber: '3',
ApplicationStatus: '04',
Organization: 0,
Product: '1101',
ApplicatonDate: '2025-05-29',
ApplicationSource: 'O',
SalesSource: null,
DeliveryMethod: 'V',
ProgramCode: null,
Campaign: null,
Plastic: null,
Design: null,
ProcessStage: '99',
ProcessStageStatus: 'S',
Score: null,
ExternalScore: null,
RequestedLimit: 0,
SuggestedLimit: null,
AssignedLimit: 0,
AllowedLimitList: null,
EligibilityCheckResult: '00',
EligibilityCheckDescription: null,
Title: 'Mr.',
FirstName: 'Abdalhamid',
SecondName: null,
ThirdName: null,
LastName: ' Ahmad',
FullName: 'Abdalhamid Ahmad',
EmbossName: 'ABDALHAMID AHMAD',
PlaceOfBirth: null,
DateOfBirth: '1999-01-07',
LocalizedDateOfBirth: '1999-01-07',
Age: 26,
Gender: 'M',
Married: 'U',
Nationality: '682',
IdType: '01',
IdNumber: '1089055972',
IdExpiryDate: '2031-09-17',
EducationLevel: null,
ProfessionCode: 0,
NumberOfDependents: 0,
EmployerName: 'N/A',
EmploymentYears: 0,
EmploymentMonths: 0,
EmployerPhoneArea: null,
EmployerPhoneNumber: null,
EmployerPhoneExtension: null,
EmployerMobile: null,
EmployerFaxArea: null,
EmployerFax: null,
EmployerCity: null,
EmployerAddress: null,
EmploymentActivity: null,
EmploymentStatus: null,
CIF: null,
BankAccountNumber: ' ',
Currency: {
CurrCode: '682',
AlphaCode: 'SAR',
},
RequestedCurrencyList: null,
CreditAccountNumber: '6000000000000000',
AccountType: '30',
OpenDate: null,
Income: 0,
AdditionalIncome: 0,
TotalIncome: 0,
CurrentBalance: 0,
AverageBalance: 0,
AssetsBalance: 0,
InsuranceBalance: 0,
DepositAmount: 0,
GuarenteeAccountNumber: null,
GuarenteeAmount: 0,
InstalmentAmount: 0,
AutoDebit: 'N',
PaymentMethod: '2',
BillingCycle: 'C1',
OldIssueDate: null,
OtherPaymentsDate: null,
MaximumDelinquency: null,
CreditBureauDecision: null,
CreditBureauUserData: null,
ECommerce: 'N',
NumberOfCards: 0,
OtherBank: null,
OtherBankDescription: null,
InsuranceProduct: null,
SocialCode: '000',
JobGrade: 0,
Flags: [
{
Position: 1,
Value: '0',
},
{
Position: 2,
Value: '0',
},
{
Position: 3,
Value: '0',
},
{
Position: 4,
Value: '0',
},
{
Position: 5,
Value: '0',
},
{
Position: 6,
Value: '0',
},
{
Position: 7,
Value: '0',
},
{
Position: 8,
Value: '0',
},
{
Position: 9,
Value: '0',
},
{
Position: 10,
Value: '0',
},
{
Position: 11,
Value: '0',
},
{
Position: 12,
Value: '0',
},
{
Position: 13,
Value: '0',
},
{
Position: 14,
Value: '0',
},
{
Position: 15,
Value: '0',
},
{
Position: 16,
Value: '0',
},
{
Position: 17,
Value: '0',
},
{
Position: 18,
Value: '0',
},
{
Position: 19,
Value: '0',
},
{
Position: 20,
Value: '0',
},
{
Position: 21,
Value: '0',
},
{
Position: 22,
Value: '0',
},
{
Position: 23,
Value: '0',
},
{
Position: 24,
Value: '0',
},
{
Position: 25,
Value: '0',
},
{
Position: 26,
Value: '0',
},
{
Position: 27,
Value: '0',
},
{
Position: 28,
Value: '0',
},
{
Position: 29,
Value: '0',
},
{
Position: 30,
Value: '0',
},
{
Position: 31,
Value: '0',
},
{
Position: 32,
Value: '0',
},
{
Position: 33,
Value: '0',
},
{
Position: 34,
Value: '0',
},
{
Position: 35,
Value: '0',
},
{
Position: 36,
Value: '0',
},
{
Position: 37,
Value: '0',
},
{
Position: 38,
Value: '0',
},
{
Position: 39,
Value: '0',
},
{
Position: 40,
Value: '0',
},
{
Position: 41,
Value: '0',
},
{
Position: 42,
Value: '0',
},
{
Position: 43,
Value: '0',
},
{
Position: 44,
Value: '0',
},
{
Position: 45,
Value: '0',
},
{
Position: 46,
Value: '0',
},
{
Position: 47,
Value: '0',
},
{
Position: 48,
Value: '0',
},
{
Position: 49,
Value: '0',
},
{
Position: 50,
Value: '0',
},
{
Position: 51,
Value: '0',
},
{
Position: 52,
Value: '0',
},
{
Position: 53,
Value: '0',
},
{
Position: 54,
Value: '0',
},
{
Position: 55,
Value: '0',
},
{
Position: 56,
Value: '0',
},
{
Position: 57,
Value: '0',
},
{
Position: 58,
Value: '0',
},
{
Position: 59,
Value: '0',
},
{
Position: 60,
Value: '0',
},
{
Position: 61,
Value: '0',
},
{
Position: 62,
Value: '0',
},
{
Position: 63,
Value: '0',
},
{
Position: 64,
Value: '0',
},
],
CheckFlags: [
{
Position: 1,
Value: '0',
},
{
Position: 2,
Value: '0',
},
{
Position: 3,
Value: '0',
},
{
Position: 4,
Value: '0',
},
{
Position: 5,
Value: '0',
},
{
Position: 6,
Value: '0',
},
{
Position: 7,
Value: '0',
},
{
Position: 8,
Value: '0',
},
{
Position: 9,
Value: '0',
},
{
Position: 10,
Value: '0',
},
{
Position: 11,
Value: '0',
},
{
Position: 12,
Value: '0',
},
{
Position: 13,
Value: '0',
},
{
Position: 14,
Value: '0',
},
{
Position: 15,
Value: '0',
},
{
Position: 16,
Value: '0',
},
{
Position: 17,
Value: '0',
},
{
Position: 18,
Value: '0',
},
{
Position: 19,
Value: '0',
},
{
Position: 20,
Value: '0',
},
{
Position: 21,
Value: '0',
},
{
Position: 22,
Value: '0',
},
{
Position: 23,
Value: '0',
},
{
Position: 24,
Value: '0',
},
{
Position: 25,
Value: '0',
},
{
Position: 26,
Value: '0',
},
{
Position: 27,
Value: '0',
},
{
Position: 28,
Value: '0',
},
{
Position: 29,
Value: '0',
},
{
Position: 30,
Value: '0',
},
{
Position: 31,
Value: '0',
},
{
Position: 32,
Value: '0',
},
{
Position: 33,
Value: '0',
},
{
Position: 34,
Value: '0',
},
{
Position: 35,
Value: '0',
},
{
Position: 36,
Value: '0',
},
{
Position: 37,
Value: '0',
},
{
Position: 38,
Value: '0',
},
{
Position: 39,
Value: '0',
},
{
Position: 40,
Value: '0',
},
{
Position: 41,
Value: '0',
},
{
Position: 42,
Value: '0',
},
{
Position: 43,
Value: '0',
},
{
Position: 44,
Value: '0',
},
{
Position: 45,
Value: '0',
},
{
Position: 46,
Value: '0',
},
{
Position: 47,
Value: '0',
},
{
Position: 48,
Value: '0',
},
{
Position: 49,
Value: '0',
},
{
Position: 50,
Value: '0',
},
{
Position: 51,
Value: '0',
},
{
Position: 52,
Value: '0',
},
{
Position: 53,
Value: '0',
},
{
Position: 54,
Value: '0',
},
{
Position: 55,
Value: '0',
},
{
Position: 56,
Value: '0',
},
{
Position: 57,
Value: '0',
},
{
Position: 58,
Value: '0',
},
{
Position: 59,
Value: '0',
},
{
Position: 60,
Value: '0',
},
{
Position: 61,
Value: '0',
},
{
Position: 62,
Value: '0',
},
{
Position: 63,
Value: '0',
},
{
Position: 64,
Value: '0',
},
],
Maker: null,
Checker: null,
ReferredTo: null,
ReferralReason: null,
UserData1: null,
UserData2: null,
UserData3: null,
UserData4: null,
UserData5: null,
AdditionalFields: [],
},
ApplicationStatusDetails: {
StatusCode: '04',
Description: 'Approved',
Canceled: false,
},
CorporateDetails: null,
CustomerDetails: {
Id: 115158,
CustomerCode: '100000024619',
IdNumber: ' ',
TypeId: 0,
PreferredLanguage: 'EN',
ExternalCustomerCode: null,
Title: ' ',
FirstName: ' ',
LastName: ' ',
DateOfBirth: null,
UserData1: '2031-09-17',
UserData2: '01',
UserData3: null,
UserData4: '682',
CustomerSegment: null,
Gender: 'U',
Married: 'U',
},
AccountDetailsList: [
{
Id: 21017,
InstitutionCode: '1100',
AccountNumber: '6899999999999999',
Currency: {
CurrCode: '682',
AlphaCode: 'SAR',
},
AccountTypeCode: '30',
ClassId: '2',
AccountStatus: '00',
VipFlag: '0',
BlockedAmount: 0,
EquivalentBlockedAmount: null,
UnclearCredit: 0,
EquivalentUnclearCredit: null,
AvailableBalance: 0,
EquivalentAvailableBalance: null,
AvailableBalanceToSpend: 0,
CreditLimit: 0,
RemainingCashLimit: null,
UserData1: 'D36407C9AE4C28D2185',
UserData2: null,
UserData3: 'D36407C9AE4C28D2185',
UserData4: null,
UserData5: 'SA2380900000752991120011',
},
],
CardDetailsList: [
{
pvv: null,
ResponseCardIdentifier: {
Id: 28595,
Pan: 'DDDDDDDDDDDDDDDDDDD',
MaskedPan: '999999_9999',
VPan: '1100000000000000',
Seqno: 0,
},
ExpiryDate: '2031-09-30',
EffectiveDate: '2025-06-02',
CardStatus: '30',
OldPlasticExpiryDate: null,
OldPlasticCardStatus: null,
EmbossingName: 'ABDALHAMID AHMAD',
Title: 'Mr.',
FirstName: 'Abdalhamid',
LastName: ' Ahmad',
Additional: false,
BatchNumber: 8849,
ServiceCode: '226',
Kinship: null,
DateOfBirth: '1999-01-07',
LastActivity: null,
LastStatusChangeDate: '2025-06-03',
ActivationDate: null,
DateLastIssued: null,
PVV: null,
UserData: '4',
UserData1: '3',
UserData2: null,
UserData3: null,
UserData4: null,
UserData5: null,
Memo: null,
CardAuthorizationParameters: null,
L10NTitle: null,
L10NFirstName: null,
L10NLastName: null,
PinStatus: '40',
OldPinStatus: '0',
CustomerIdNumber: '1089055972',
Language: 0,
},
],
},
};

View File

@ -0,0 +1,2 @@
export * from './create-application.mock';
export * from './inquire-application.mock';

View File

@ -0,0 +1,728 @@
export const INQUIRE_APPLICATION_MOCK = {
ResponseHeader: {
Version: '1.0.0',
MsgUid: 'adaa1893-9f95-48a8-b7a1-0422bcf629b4',
Source: 'ZOD',
ServiceId: 'InquireApplication',
ReqDateTime: '2023-07-18T10:34:12.553Z',
RspDateTime: '2025-06-03T11:14:54.748',
ResponseCode: '000',
ResponseType: 'Success',
ProcessingTime: 476,
EncryptionKey: null,
ResponseDescription: 'Operation Successful',
LocalizedResponseDescription: null,
CustomerSpecificResponseDescriptionList: null,
HeaderUserDataList: null,
},
InquireApplicationResponseDetails: {
InstitutionCode: '1100',
ApplicationTypeDetails: {
TypeCode: '01',
Description: 'Normal Primary',
Additional: false,
Corporate: false,
UserData: null,
},
ApplicationDetails: {
cif: null,
ApplicationNumber: '3300000000070',
ExternalApplicationNumber: '10000002',
ApplicationStatus: '04',
Organization: 0,
Product: '1101',
ApplicatonDate: '2025-05-29',
ApplicationSource: 'O',
SalesSource: null,
DeliveryMethod: 'V',
ProgramCode: null,
Campaign: null,
Plastic: null,
Design: null,
ProcessStage: '99',
ProcessStageStatus: 'S',
Score: null,
ExternalScore: null,
RequestedLimit: 0,
SuggestedLimit: null,
AssignedLimit: 0,
AllowedLimitList: null,
EligibilityCheckResult: '00',
EligibilityCheckDescription: null,
Title: 'Mr.',
FirstName: 'Abdalhamid',
SecondName: null,
ThirdName: null,
LastName: ' Ahmad',
FullName: 'Abdalhamid Ahmad',
EmbossName: 'ABDALHAMID AHMAD',
PlaceOfBirth: null,
DateOfBirth: '1999-01-07',
LocalizedDateOfBirth: '1999-01-07',
Age: 26,
Gender: 'M',
Married: 'U',
Nationality: '682',
IdType: '01',
IdNumber: '1089055972',
IdExpiryDate: '2031-09-17',
EducationLevel: null,
ProfessionCode: 0,
NumberOfDependents: 0,
EmployerName: 'N/A',
EmploymentYears: 0,
EmploymentMonths: 0,
EmployerPhoneArea: null,
EmployerPhoneNumber: null,
EmployerPhoneExtension: null,
EmployerMobile: null,
EmployerFaxArea: null,
EmployerFax: null,
EmployerCity: null,
EmployerAddress: null,
EmploymentActivity: null,
EmploymentStatus: null,
CIF: null,
BankAccountNumber: ' ',
Currency: {
CurrCode: '682',
AlphaCode: 'SAR',
},
RequestedCurrencyList: null,
CreditAccountNumber: '6823000000000019',
AccountType: '30',
OpenDate: null,
Income: 0,
AdditionalIncome: 0,
TotalIncome: 0,
CurrentBalance: 0,
AverageBalance: 0,
AssetsBalance: 0,
InsuranceBalance: 0,
DepositAmount: 0,
GuarenteeAccountNumber: null,
GuarenteeAmount: 0,
InstalmentAmount: 0,
AutoDebit: 'N',
PaymentMethod: '2',
BillingCycle: 'C1',
OldIssueDate: null,
OtherPaymentsDate: null,
MaximumDelinquency: null,
CreditBureauDecision: null,
CreditBureauUserData: null,
ECommerce: 'N',
NumberOfCards: 0,
OtherBank: null,
OtherBankDescription: null,
InsuranceProduct: null,
SocialCode: '000',
JobGrade: 0,
Flags: [
{
Position: 1,
Value: '0',
},
{
Position: 2,
Value: '0',
},
{
Position: 3,
Value: '0',
},
{
Position: 4,
Value: '0',
},
{
Position: 5,
Value: '0',
},
{
Position: 6,
Value: '0',
},
{
Position: 7,
Value: '0',
},
{
Position: 8,
Value: '0',
},
{
Position: 9,
Value: '0',
},
{
Position: 10,
Value: '0',
},
{
Position: 11,
Value: '0',
},
{
Position: 12,
Value: '0',
},
{
Position: 13,
Value: '0',
},
{
Position: 14,
Value: '0',
},
{
Position: 15,
Value: '0',
},
{
Position: 16,
Value: '0',
},
{
Position: 17,
Value: '0',
},
{
Position: 18,
Value: '0',
},
{
Position: 19,
Value: '0',
},
{
Position: 20,
Value: '0',
},
{
Position: 21,
Value: '0',
},
{
Position: 22,
Value: '0',
},
{
Position: 23,
Value: '0',
},
{
Position: 24,
Value: '0',
},
{
Position: 25,
Value: '0',
},
{
Position: 26,
Value: '0',
},
{
Position: 27,
Value: '0',
},
{
Position: 28,
Value: '0',
},
{
Position: 29,
Value: '0',
},
{
Position: 30,
Value: '0',
},
{
Position: 31,
Value: '0',
},
{
Position: 32,
Value: '0',
},
{
Position: 33,
Value: '0',
},
{
Position: 34,
Value: '0',
},
{
Position: 35,
Value: '0',
},
{
Position: 36,
Value: '0',
},
{
Position: 37,
Value: '0',
},
{
Position: 38,
Value: '0',
},
{
Position: 39,
Value: '0',
},
{
Position: 40,
Value: '0',
},
{
Position: 41,
Value: '0',
},
{
Position: 42,
Value: '0',
},
{
Position: 43,
Value: '0',
},
{
Position: 44,
Value: '0',
},
{
Position: 45,
Value: '0',
},
{
Position: 46,
Value: '0',
},
{
Position: 47,
Value: '0',
},
{
Position: 48,
Value: '0',
},
{
Position: 49,
Value: '0',
},
{
Position: 50,
Value: '0',
},
{
Position: 51,
Value: '0',
},
{
Position: 52,
Value: '0',
},
{
Position: 53,
Value: '0',
},
{
Position: 54,
Value: '0',
},
{
Position: 55,
Value: '0',
},
{
Position: 56,
Value: '0',
},
{
Position: 57,
Value: '0',
},
{
Position: 58,
Value: '0',
},
{
Position: 59,
Value: '0',
},
{
Position: 60,
Value: '0',
},
{
Position: 61,
Value: '0',
},
{
Position: 62,
Value: '0',
},
{
Position: 63,
Value: '0',
},
{
Position: 64,
Value: '0',
},
],
CheckFlags: [
{
Position: 1,
Value: '0',
},
{
Position: 2,
Value: '0',
},
{
Position: 3,
Value: '0',
},
{
Position: 4,
Value: '0',
},
{
Position: 5,
Value: '0',
},
{
Position: 6,
Value: '0',
},
{
Position: 7,
Value: '0',
},
{
Position: 8,
Value: '0',
},
{
Position: 9,
Value: '0',
},
{
Position: 10,
Value: '0',
},
{
Position: 11,
Value: '0',
},
{
Position: 12,
Value: '0',
},
{
Position: 13,
Value: '0',
},
{
Position: 14,
Value: '0',
},
{
Position: 15,
Value: '0',
},
{
Position: 16,
Value: '0',
},
{
Position: 17,
Value: '0',
},
{
Position: 18,
Value: '0',
},
{
Position: 19,
Value: '0',
},
{
Position: 20,
Value: '0',
},
{
Position: 21,
Value: '0',
},
{
Position: 22,
Value: '0',
},
{
Position: 23,
Value: '0',
},
{
Position: 24,
Value: '0',
},
{
Position: 25,
Value: '0',
},
{
Position: 26,
Value: '0',
},
{
Position: 27,
Value: '0',
},
{
Position: 28,
Value: '0',
},
{
Position: 29,
Value: '0',
},
{
Position: 30,
Value: '0',
},
{
Position: 31,
Value: '0',
},
{
Position: 32,
Value: '0',
},
{
Position: 33,
Value: '0',
},
{
Position: 34,
Value: '0',
},
{
Position: 35,
Value: '0',
},
{
Position: 36,
Value: '0',
},
{
Position: 37,
Value: '0',
},
{
Position: 38,
Value: '0',
},
{
Position: 39,
Value: '0',
},
{
Position: 40,
Value: '0',
},
{
Position: 41,
Value: '0',
},
{
Position: 42,
Value: '0',
},
{
Position: 43,
Value: '0',
},
{
Position: 44,
Value: '0',
},
{
Position: 45,
Value: '0',
},
{
Position: 46,
Value: '0',
},
{
Position: 47,
Value: '0',
},
{
Position: 48,
Value: '0',
},
{
Position: 49,
Value: '0',
},
{
Position: 50,
Value: '0',
},
{
Position: 51,
Value: '0',
},
{
Position: 52,
Value: '0',
},
{
Position: 53,
Value: '0',
},
{
Position: 54,
Value: '0',
},
{
Position: 55,
Value: '0',
},
{
Position: 56,
Value: '0',
},
{
Position: 57,
Value: '0',
},
{
Position: 58,
Value: '0',
},
{
Position: 59,
Value: '0',
},
{
Position: 60,
Value: '0',
},
{
Position: 61,
Value: '0',
},
{
Position: 62,
Value: '0',
},
{
Position: 63,
Value: '0',
},
{
Position: 64,
Value: '0',
},
],
Maker: null,
Checker: null,
ReferredTo: null,
ReferralReason: null,
UserData1: null,
UserData2: null,
UserData3: null,
UserData4: null,
UserData5: null,
AdditionalFields: [],
},
ApplicationStatusDetails: {
StatusCode: '04',
Description: 'Approved',
Canceled: false,
},
ApplicationHistoryList: null,
ApplicationAddressList: [
{
Id: 43859,
AddressLine1: '5536 abdullah Ibn al zubair ',
AddressLine2: ' Umm Alarad Dist.',
AddressLine3: null,
AddressLine4: null,
AddressLine5: null,
Directions: null,
City: 'AT TAIF',
PostalCode: null,
Province: null,
Territory: null,
State: null,
Region: null,
County: null,
Country: '682',
CountryDetails: {
IsoCode: '682',
Alpha3: 'SAU',
Alpha2: 'SA',
DefaultCurrency: {
CurrCode: '682',
AlphaCode: 'SAR',
},
Description: [
{
Language: 'EN',
Description: 'SAUDI ARABIA',
},
{
Language: 'GB',
Description: 'SAUDI ARABIA',
},
],
},
Phone1: '+966541884784',
Phone2: null,
Extension: null,
Email: 'a.ahmad@zod-alkhair.com',
Fax: null,
District: null,
PoBox: null,
OwnershipType: 'O',
UserData1: null,
UserData2: null,
AddressRole: 0,
AddressCustomValues: null,
},
],
CorporateDetails: null,
CustomerDetails: {
Id: 115129,
CustomerCode: '100000024552',
IdNumber: ' ',
TypeId: 0,
PreferredLanguage: 'EN',
ExternalCustomerCode: null,
Title: ' ',
FirstName: ' ',
LastName: ' ',
DateOfBirth: null,
UserData1: '2031-09-17',
UserData2: '01',
UserData3: null,
UserData4: '682',
CustomerSegment: null,
Gender: 'U',
Married: 'U',
},
BranchDetails: null,
CardAccountLinkageList: null,
},
};

View File

@ -0,0 +1,29 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
AccountCardStatusChangedWebhookRequest,
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
} from '../dtos/requests';
import { NeoLeapWebhookService } from '../services';
@Controller('neoleap-webhooks')
@ApiTags('Neoleap Webhooks')
export class NeoLeapWebhooksController {
constructor(private readonly neoleapWebhookService: NeoLeapWebhookService) {}
@Post('card-transaction')
async handleCardTransactionWebhook(@Body() body: CardTransactionWebhookRequest) {
return this.neoleapWebhookService.handleCardTransactionWebhook(body);
}
@Post('account-transaction')
async handleAccountTransactionWebhook(@Body() body: AccountTransactionWebhookRequest) {
return this.neoleapWebhookService.handleAccountTransactionWebhook(body);
}
@Post('account-card-status-changed')
async handleAccountCardStatusChangedWebhook(@Body() body: AccountCardStatusChangedWebhookRequest) {
return this.neoleapWebhookService.handleAccountCardStatusChangedWebhook(body);
}
}

View File

@ -0,0 +1,62 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { IJwtPayload } from '~/auth/interfaces';
import { CardService } from '~/card/services';
import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { CustomerResponseDto } from '~/customer/dtos/response';
import { CustomerService } from '~/customer/services';
import { UpdateCardControlsRequestDto } from '../dtos/requests';
import { CreateApplicationResponse, InquireApplicationResponse } from '../dtos/response';
import { NeoLeapService } from '../services/neoleap.service';
@Controller('neotest')
@ApiTags('Neoleap Test API , for testing purposes only, will be removed in production')
@UseGuards(AccessTokenGuard)
@ApiBearerAuth()
export class NeoTestController {
constructor(
private readonly neoleapService: NeoLeapService,
private readonly customerService: CustomerService,
private readonly cardService: CardService,
private readonly configService: ConfigService,
) {}
@Post('update-kys')
@ApiDataResponse(CustomerResponseDto)
async updateKys(@AuthenticatedUser() user: IJwtPayload) {
const customer = await this.customerService.updateKyc(user.sub);
return ResponseFactory.data(new CustomerResponseDto(customer));
}
@Post('inquire-application')
@ApiDataResponse(InquireApplicationResponse)
async inquireApplication(@AuthenticatedUser() user: IJwtPayload) {
const customer = await this.customerService.findCustomerById(user.sub);
const data = await this.neoleapService.inquireApplication(customer.applicationNumber.toString());
return ResponseFactory.data(data);
}
@Post('create-application')
@ApiDataResponse(CreateApplicationResponse)
async createApplication(@AuthenticatedUser() user: IJwtPayload) {
const customer = await this.customerService.findCustomerById(user.sub);
const data = await this.neoleapService.createApplication(customer);
await this.cardService.createCard(customer.id, data);
return ResponseFactory.data(data);
}
@Post('update-card-controls')
async updateCardControls(
@AuthenticatedUser() user: IJwtPayload,
@Body() { amount, count }: UpdateCardControlsRequestDto,
) {
const card = await this.cardService.getActiveCardForCustomer(user.sub);
await this.neoleapService.updateCardControl(card.cardReference, amount, count);
return ResponseFactory.data({ message: 'Card controls updated successfully' });
}
}

View File

@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose } from 'class-transformer';
import { IsString } from 'class-validator';
export class AccountCardStatusChangedWebhookRequest {
@ApiProperty()
@Expose({ name: 'InstId' })
@IsString()
instId!: string;
@ApiProperty()
@Expose({ name: 'cardId' })
@IsString()
cardId!: string;
@ApiProperty()
@Expose({ name: 'newStatus' })
@IsString()
newStatus!: string;
}

View File

@ -0,0 +1,68 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Type } from 'class-transformer';
import { IsNumber, IsPositive, IsString } from 'class-validator';
export class AccountTransactionWebhookRequest {
@Expose({ name: 'InstId' })
@IsString()
@ApiProperty({ name: 'InstId', example: '9000' })
instId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '143761' })
transactionId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '0037' })
transactionType!: string;
@Expose()
@IsString()
@ApiProperty({ example: '26' })
transactionCode!: string;
@Expose()
@IsString()
@ApiProperty({ example: '6823000000000018' })
accountNumber!: string;
@Expose()
@IsString()
@ApiProperty({ example: '6823000000000018' })
accountId!: string;
@Expose()
@Type(() => Number)
@IsNumber()
@ApiProperty({ example: '7080.15' })
otb!: number;
@Expose()
@Type(() => Number)
@IsNumber()
@IsPositive()
@ApiProperty({ example: '3050.95' })
amount!: number;
@Expose()
@IsString()
@ApiProperty({ example: 'C' })
sign!: string;
@Expose()
@IsString()
@ApiProperty({ example: '682' })
currency!: string;
@Expose()
@IsString()
@ApiProperty({ name: 'Date', example: '20241112' })
date!: string;
@Expose()
@IsString()
@ApiProperty({ name: 'Time', example: '125340' })
time!: string;
}

View File

@ -0,0 +1,158 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Type } from 'class-transformer';
import { IsNumber, IsString, Min, ValidateNested } from 'class-validator';
export class CardAcceptorLocationDto {
@Expose()
@IsString()
@ApiProperty()
merchantId!: string;
@Expose()
@IsString()
@ApiProperty()
merchantName!: string;
@Expose()
@IsString()
@ApiProperty()
merchantCountry!: string;
@Expose()
@IsString()
@ApiProperty()
merchantCity!: string;
@Expose()
@IsString()
@ApiProperty()
mcc!: string;
}
export class CardTransactionWebhookRequest {
@Expose({ name: 'InstId' })
@IsString()
@ApiProperty({ name: 'InstId', example: '1100' })
instId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '30829' })
cardId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '1234567890123456' })
transactionId!: string;
@Expose()
@IsString()
@ApiProperty({ example: '277012*****3456' })
cardMaskedNumber!: string;
@Expose()
@IsString()
@ApiProperty({ example: '1234567890123456' })
accountNumber!: string;
@Expose({ name: 'Date' })
@IsString()
@ApiProperty({ name: 'Date', example: '20241112' })
date!: string;
@Expose({ name: 'Time' })
@IsString()
@ApiProperty({ name: 'Time', example: '125250' })
time!: string;
@Expose()
@Type(() => Number)
@IsNumber()
@ApiProperty({ example: '132' })
otb!: number;
@Expose()
@IsString()
@ApiProperty({ example: '0' })
transactionCode!: string;
@Expose()
@IsString()
@ApiProperty({ example: '1' })
messageClass!: string;
@Expose({ name: 'RRN' })
@IsString()
@ApiProperty({ name: 'RRN', example: '431712003306' })
rrn!: string;
@Expose()
@IsString()
@ApiProperty({ example: '3306' })
stan!: string;
@Expose()
@ValidateNested()
@Type(() => CardAcceptorLocationDto)
@ApiProperty({ type: CardAcceptorLocationDto })
cardAcceptorLocation!: CardAcceptorLocationDto;
@Type(() => Number)
@IsNumber()
@Min(0, { message: 'amount must be zero or a positive number' })
@ApiProperty({ example: '100.5' })
transactionAmount!: number;
@IsString()
@ApiProperty({ example: '682' })
transactionCurrency!: string;
@Type(() => Number)
@IsNumber()
@Min(0, { message: 'amount must be zero or a positive number' })
@ApiProperty({ example: '100.5' })
billingAmount!: number;
@IsString()
@ApiProperty({ example: '682' })
billingCurrency!: string;
@Type(() => Number)
@IsNumber()
@Min(0, { message: 'amount must be zero or a positive number' })
@ApiProperty({ example: '100.5' })
settlementAmount!: number;
@IsString()
@ApiProperty({ example: '682' })
settlementCurrency!: string;
@Expose()
@Type(() => Number)
@IsNumber()
@Min(0, { message: 'amount must be zero or a positive number' })
@ApiProperty({ example: '20' })
fees!: number;
@Expose()
@Type(() => Number)
@IsNumber()
@Min(0, { message: 'amount must be zero or a positive number' })
@ApiProperty({ example: '4.5' })
vatOnFees!: number;
@Expose()
@IsString()
@ApiProperty({ example: '9' })
posEntryMode!: string;
@Expose()
@IsString()
@ApiProperty({ example: '036657' })
authIdResponse!: string;
@Expose({ name: 'POSCDIM' })
@IsString()
@ApiProperty({ name: 'POSCDIM', example: '9' })
posCdim!: string;
}

View File

@ -0,0 +1,4 @@
export * from './account-card-status-changed-webhook.request.dto';
export * from './account-transaction-webhook.request.dto';
export * from './card-transaction-webhook.request.dto';
export * from './update-card-controls.request.dto';

View File

@ -0,0 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsPositive } from 'class-validator';
export class UpdateCardControlsRequestDto {
@ApiProperty()
@IsNumber()
@IsPositive()
amount!: number;
@IsNumber()
@IsPositive()
@IsOptional()
@ApiPropertyOptional()
count?: number;
}

View File

@ -0,0 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Transform } from 'class-transformer';
import { InquireApplicationResponse } from './inquire-application.response';
export class CreateApplicationResponse extends InquireApplicationResponse {
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier.Id.toString())
@Expose()
@ApiProperty()
cardId!: string;
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier.VPan)
@Expose()
@ApiProperty()
vpan!: string;
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ExpiryDate)
@Expose()
@ApiProperty()
expiryDate!: string;
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.CardStatus)
@Expose()
@ApiProperty()
cardStatus!: string;
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier?.MaskedPan?.split('_')[0])
@Expose()
@ApiProperty()
firstSixDigits!: string;
@Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier?.MaskedPan?.split('_')[1])
@Expose()
@ApiProperty()
lastFourDigits!: string;
@Transform(({ obj }) => obj.AccountDetailsList?.[0]?.Id.toString())
@Expose()
@ApiProperty()
accountId!: string;
}

View File

@ -0,0 +1,3 @@
export * from './create-application.response.dto';
export * from './inquire-application.response';
export * from './update-card-controls.response.dto';

View File

@ -0,0 +1,179 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Transform } from 'class-transformer';
export class InquireApplicationResponse {
@Transform(({ obj }) => obj.ApplicationDetails?.ApplicationNumber)
@Expose()
@ApiProperty()
applicationNumber!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ExternalApplicationNumber)
@Expose()
@ApiProperty()
externalApplicationNumber!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ApplicationStatus)
@Expose()
@ApiProperty()
applicationStatus!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Organization)
@Expose()
@ApiProperty()
organization!: number;
@Transform(({ obj }) => obj.ApplicationDetails?.Product)
@Expose()
@ApiProperty()
product!: string;
// this typo is from neoleap, so we keep it as is
@Transform(({ obj }) => obj.ApplicationDetails?.ApplicatonDate)
@Expose()
@ApiProperty()
applicationDate!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ApplicationSource)
@Expose()
@ApiProperty()
applicationSource!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.SalesSource)
@Expose()
@ApiProperty()
salesSource!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.DeliveryMethod)
@Expose()
@ApiProperty()
deliveryMethod!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ProgramCode)
@Expose()
@ApiProperty()
ProgramCode!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Plastic)
@Expose()
@ApiProperty()
plastic!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Design)
@Expose()
@ApiProperty()
design!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ProcessStage)
@Expose()
@ApiProperty()
processStage!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ProcessStageStatus)
@Expose()
@ApiProperty()
processStageStatus!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.EligibilityCheckResult)
@Expose()
@ApiProperty()
eligibilityCheckResult!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.EligibilityCheckDescription)
@Expose()
@ApiProperty()
eligibilityCheckDescription!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Title)
@Expose()
@ApiProperty()
title!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.FirstName)
@Expose()
@ApiProperty()
firstName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.SecondName)
@Expose()
@ApiProperty()
secondName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.ThirdName)
@Expose()
@ApiProperty()
thirdName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.LastName)
@Expose()
@ApiProperty()
lastName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.FullName)
@Expose()
@ApiProperty()
fullName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.EmbossName)
@Expose()
@ApiProperty()
embossName!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.PlaceOfBirth)
@Expose()
@ApiProperty()
placeOfBirth!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.DateOfBirth)
@Expose()
@ApiProperty()
dateOfBirth!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.LocalizedDateOfBirth)
@Expose()
@ApiProperty()
localizedDateOfBirth!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Age)
@Expose()
@ApiProperty()
age!: number;
@Transform(({ obj }) => obj.ApplicationDetails?.Gender)
@Expose()
@ApiProperty()
gender!: string;
@Transform(({ obj }) => obj.ApplicationDetails?.Married)
@Expose()
@ApiProperty()
married!: string;
@Transform(({ obj }) => obj.ApplicationDetails.Nationality)
@Expose()
@ApiProperty()
nationality!: string;
@Transform(({ obj }) => obj.ApplicationDetails.IdType)
@Expose()
@ApiProperty()
idType!: string;
@Transform(({ obj }) => obj.ApplicationDetails.IdNumber)
@Expose()
@ApiProperty()
idNumber!: string;
@Transform(({ obj }) => obj.ApplicationDetails.IdExpiryDate)
@Expose()
@ApiProperty()
idExpiryDate!: string;
@Transform(({ obj }) => obj.ApplicationStatusDetails?.Description)
@Expose()
@ApiProperty()
applicationStatusDescription!: string;
@Transform(({ obj }) => obj.ApplicationStatusDetails?.Canceled)
@Expose()
@ApiProperty()
canceled!: boolean;
}

View File

@ -0,0 +1 @@
export class UpdateCardControlsResponseDto {}

View File

@ -0,0 +1,61 @@
import { INeoleapHeaderRequest } from './neoleap-header.request.interface';
export interface ICreateApplicationRequest extends INeoleapHeaderRequest {
CreateNewApplicationRequestDetails: {
ApplicationRequestDetails: {
InstitutionCode: string;
ExternalApplicationNumber: string;
ApplicationType: string;
Product: string;
ApplicationDate: string;
BranchCode: '000';
ApplicationSource: 'O';
DeliveryMethod: 'V';
};
ApplicationProcessingDetails: {
ProcessControl: string;
RequestedLimit: number;
SuggestedLimit: number;
AssignedLimit: number;
};
ApplicationFinancialInformation: {
Currency: {
AlphaCode: 'SAR';
};
BillingCycle: 'C1';
};
ApplicationOtherInfo: object;
ApplicationCustomerDetails: {
Title: string;
FirstName: string;
LastName: string;
FullName: string;
EmbossName: string;
DateOfBirth: string;
LocalizedDateOfBirth: string;
Gender: string;
Nationality: string;
IdType: string;
IdNumber: string;
IdExpiryDate: string;
};
ApplicationAddress: {
AddressLine1: string;
AddressLine2: string;
City: string;
Region: string;
Country: string;
CountryDetails: {
DefaultCurrency: {};
Description: [];
};
Phone1: string;
Email: string;
AddressRole: number;
};
};
}

View File

@ -0,0 +1,4 @@
export * from './create-application.request.interface';
export * from './inquire-application.request.interface';
export * from './neoleap-header.request.interface';
export * from './update-card-control.request.interface';

View File

@ -0,0 +1,22 @@
import { INeoleapHeaderRequest } from './neoleap-header.request.interface';
export interface IInquireApplicationRequest extends INeoleapHeaderRequest {
InquireApplicationRequestDetails: {
ApplicationIdentifier: {
InstitutionCode: string;
ExternalApplicationNumber: string;
};
AdditionalData?: {
ReturnApplicationType?: boolean;
ReturnApplicationStatus?: boolean;
ReturnAddresses?: boolean;
ReturnBranch?: boolean;
ReturnHistory?: boolean;
ReturnCard?: boolean;
ReturnCustomer?: boolean;
ReturnAccount?: boolean;
ReturnDirectDebitDetails?: boolean;
};
HistoryTypeFilterList?: number[];
};
}

View File

@ -0,0 +1,9 @@
export interface INeoleapHeaderRequest {
RequestHeader: {
Version: string;
MsgUid: string;
Source: 'ZOD';
ServiceId: string;
ReqDateTime: Date;
};
}

View File

@ -0,0 +1,77 @@
import { INeoleapHeaderRequest } from './neoleap-header.request.interface';
export interface IUpdateCardControlRequest extends INeoleapHeaderRequest {
UpdateCardControlsRequestDetails: {
InstitutionCode: string;
CardIdentifier: {
Id: string;
InstitutionCode: string;
};
UsageTransactionLimit?: {
AmountLimit: number;
CountLimit: number;
};
DomesticCashDailyLimit?: {
AmountLimit: number;
CountLimit: number;
};
InternationalCashDailyLimit?: {
AmountLimit: number;
CountLimit: number;
};
DomesticPosDailyLimit?: {
AmountLimit: number;
CountLimit: number;
lenient?: boolean;
};
InternationalPosDailyLimit?: {
AmountLimit: number;
CountLimit: number;
};
EcommerceDailyLimit?: {
AmountLimit: number;
CountLimit: number;
};
DomesticCashMonthlyLimit?: {
AmountLimit: number;
CountLimit: number;
};
InternationalCashMonthlyLimit?: {
AmountLimit: number;
CountLimit: number;
};
DomesticPosMonthlyLimit?: {
AmountLimit: number;
CountLimit: number;
};
InternationalPosMonthlyLimit?: {
AmountLimit: number;
CountLimit: number;
};
EcommerceMonthlyLimit?: {
AmountLimit: number;
CountLimit: number;
};
DomesticCashTransactionLimit?: {
AmountLimit: number;
CountLimit: number;
};
InternationalCashTransactionLimit?: {
AmountLimit: number;
CountLimit: number;
};
DomesticPosTransactionLimit?: {
AmountLimit: number;
CountLimit: number;
};
InternationalPosTransactionLimit?: {
AmountLimit: number;
CountLimit: number;
};
EcommerceTransactionLimit?: {
AmountLimit: number;
CountLimit: number;
};
};
}

View File

@ -0,0 +1,15 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { CardModule } from '~/card/card.module';
import { CustomerModule } from '~/customer/customer.module';
import { NeoLeapWebhooksController } from './controllers/neoleap-webhooks.controller';
import { NeoTestController } from './controllers/neotest.controller';
import { NeoLeapWebhookService } from './services';
import { NeoLeapService } from './services/neoleap.service';
@Module({
imports: [HttpModule, CustomerModule, CardModule],
controllers: [NeoTestController, NeoLeapWebhooksController],
providers: [NeoLeapService, NeoLeapWebhookService],
})
export class NeoLeapModule {}

View File

@ -0,0 +1,2 @@
export * from './neoleap-webook.service';
export * from './neoleap.service';

View File

@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { CardService } from '~/card/services';
import { TransactionService } from '~/card/services/transaction.service';
import {
AccountCardStatusChangedWebhookRequest,
AccountTransactionWebhookRequest,
CardTransactionWebhookRequest,
} from '../dtos/requests';
@Injectable()
export class NeoLeapWebhookService {
constructor(private readonly transactionService: TransactionService, private readonly cardService: CardService) {}
handleCardTransactionWebhook(body: CardTransactionWebhookRequest) {
return this.transactionService.createCardTransaction(body);
}
handleAccountTransactionWebhook(body: AccountTransactionWebhookRequest) {
return this.transactionService.createAccountTransaction(body);
}
handleAccountCardStatusChangedWebhook(body: AccountCardStatusChangedWebhookRequest) {
return this.cardService.updateCardStatus(body);
}
}

View File

@ -0,0 +1,217 @@
import { HttpService } from '@nestjs/axios';
import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ClassConstructor, plainToInstance } from 'class-transformer';
import moment from 'moment';
import { v4 as uuid } from 'uuid';
import { CountriesNumericISO } from '~/common/constants';
import { Customer } from '~/customer/entities';
import { Gender, KycStatus } from '~/customer/enums';
import { CREATE_APPLICATION_MOCK, INQUIRE_APPLICATION_MOCK } from '../__mocks__/';
import { CreateApplicationResponse, InquireApplicationResponse, UpdateCardControlsResponseDto } from '../dtos/response';
import {
ICreateApplicationRequest,
IInquireApplicationRequest,
INeoleapHeaderRequest,
IUpdateCardControlRequest,
} from '../interfaces';
@Injectable()
export class NeoLeapService {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly useGateway: boolean;
private readonly institutionCode = '1100';
useLocalCert: boolean;
constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) {
this.baseUrl = this.configService.getOrThrow<string>('GATEWAY_URL');
this.apiKey = this.configService.getOrThrow<string>('GATEWAY_API_KEY');
this.useGateway = [true, 'true'].includes(this.configService.get<boolean>('USE_GATEWAY', false));
this.useLocalCert = this.configService.get<boolean>('USE_LOCAL_CERT', false);
}
async createApplication(customer: Customer) {
const responseKey = 'CreateNewApplicationResponseDetails';
if (customer.kycStatus !== KycStatus.APPROVED) {
throw new BadRequestException('CUSTOMER.KYC_NOT_APPROVED');
}
if (customer.cards.length > 0) {
throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD');
}
if (!this.useGateway) {
return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], {
excludeExtraneousValues: true,
});
}
const payload: ICreateApplicationRequest = {
CreateNewApplicationRequestDetails: {
ApplicationRequestDetails: {
InstitutionCode: this.institutionCode,
ExternalApplicationNumber: customer.applicationNumber.toString(),
ApplicationType: '01',
Product: '1101',
ApplicationDate: moment().format('YYYY-MM-DD'),
BranchCode: '000',
ApplicationSource: 'O',
DeliveryMethod: 'V',
},
ApplicationProcessingDetails: {
SuggestedLimit: 0,
RequestedLimit: 0,
AssignedLimit: 0,
ProcessControl: 'STND',
},
ApplicationFinancialInformation: {
Currency: {
AlphaCode: 'SAR',
},
BillingCycle: 'C1',
},
ApplicationOtherInfo: {},
ApplicationCustomerDetails: {
FirstName: customer.firstName,
LastName: customer.lastName,
FullName: customer.fullName,
DateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'),
EmbossName: customer.fullName.toUpperCase(), // TODO Enter Emboss Name
IdType: '01',
IdNumber: customer.nationalId,
IdExpiryDate: moment(customer.nationalIdExpiry).format('YYYY-MM-DD'),
Title: customer.gender === Gender.MALE ? 'Mr' : 'Ms',
Gender: customer.gender === Gender.MALE ? 'M' : 'F',
LocalizedDateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'),
Nationality: CountriesNumericISO[customer.countryOfResidence],
},
ApplicationAddress: {
City: customer.city,
Country: CountriesNumericISO[customer.country],
Region: customer.region,
AddressLine1: `${customer.street} ${customer.building}`,
AddressLine2: customer.neighborhood,
AddressRole: 0,
Email: customer.user.email,
Phone1: customer.user.phoneNumber,
CountryDetails: {
DefaultCurrency: {},
Description: [],
},
},
},
RequestHeader: this.prepareHeaders('CreateNewApplication'),
};
return this.sendRequestToNeoLeap<ICreateApplicationRequest, CreateApplicationResponse>(
'application/CreateNewApplication',
payload,
responseKey,
CreateApplicationResponse,
);
}
async inquireApplication(externalApplicationNumber: string) {
const responseKey = 'InquireApplicationResponseDetails';
if (!this.useGateway) {
return plainToInstance(InquireApplicationResponse, INQUIRE_APPLICATION_MOCK[responseKey], {
excludeExtraneousValues: true,
});
}
const payload = {
InquireApplicationRequestDetails: {
ApplicationIdentifier: {
InstitutionCode: this.institutionCode,
ExternalApplicationNumber: externalApplicationNumber,
},
AdditionalData: {
ReturnApplicationType: true,
ReturnApplicationStatus: true,
ReturnCustomer: true,
ReturnAddresses: true,
},
},
RequestHeader: this.prepareHeaders('InquireApplication'),
};
return this.sendRequestToNeoLeap<IInquireApplicationRequest, InquireApplicationResponse>(
'application/InquireApplication',
payload,
responseKey,
InquireApplicationResponse,
);
}
async updateCardControl(cardId: string, amount: number, count?: number) {
const responseKey = 'UpdateCardControlResponseDetails';
if (!this.useGateway) {
return;
}
const payload: IUpdateCardControlRequest = {
UpdateCardControlsRequestDetails: {
InstitutionCode: this.institutionCode,
CardIdentifier: {
InstitutionCode: this.institutionCode,
Id: cardId,
},
UsageTransactionLimit: {
AmountLimit: amount,
CountLimit: count || 10,
},
},
RequestHeader: this.prepareHeaders('UpdateCardControl'),
};
return this.sendRequestToNeoLeap<IUpdateCardControlRequest, UpdateCardControlsResponseDto>(
'cardcontrol/UpdateCardControl',
payload,
responseKey,
UpdateCardControlsResponseDto,
);
}
private prepareHeaders(serviceName: string): INeoleapHeaderRequest['RequestHeader'] {
return {
Version: '1.0.0',
MsgUid: uuid(),
Source: 'ZOD',
ServiceId: serviceName,
ReqDateTime: new Date(),
};
}
private async sendRequestToNeoLeap<T, R>(
endpoint: string,
payload: T,
responseKey: string,
responseClass: ClassConstructor<R>,
): Promise<R> {
try {
const { data } = await this.httpService.axiosRef.post(`${this.baseUrl}/${endpoint}`, payload, {
headers: {
'Content-Type': 'application/json',
Authorization: `${this.apiKey}`,
},
});
if (data.data?.ResponseHeader?.ResponseCode !== '000') {
throw new BadRequestException(
data.data.ResponseHeader.ResponseDescription || 'Unexpected Error from Service Provider',
);
}
return plainToInstance(responseClass, data.data[responseKey], {
excludeExtraneousValues: true,
});
} catch (error: any) {
if (error.status === 400) {
console.error('Error sending request to NeoLeap:', error);
throw new BadRequestException(error.response?.data?.ResponseHeader?.ResponseDescription || error.message);
}
console.error('Error sending request to NeoLeap:', error);
throw new InternalServerErrorException('Error communicating with NeoLeap service');
}
}
}

View File

@ -1,4 +1,8 @@
export enum UserLocale {
ARABIC = 'ar',
ENGLISH = 'en',
}
import { ObjectValues } from '../types';
export const UserLocale = {
ARABIC: 'ar',
ENGLISH: 'en',
} as const;
export type UserLocale = ObjectValues<typeof UserLocale>;

View File

@ -9,7 +9,7 @@ export function buildValidationPipe(config: ConfigService): ValidationPipe {
transform: true,
validateCustomDecorators: true,
stopAtFirstError: true,
forbidNonWhitelisted: true,
forbidNonWhitelisted: false,
dismissDefaultMessages: true,
enableDebugMessages: config.getOrThrow('NODE_ENV') === Environment.DEV,
exceptionFactory: i18nValidationErrorFactory,

View File

@ -58,6 +58,24 @@ export class CustomerResponseDto {
@ApiProperty()
waitingNumber!: number;
@ApiProperty()
country!: string | null;
@ApiProperty()
region!: string | null;
@ApiProperty()
city!: string | null;
@ApiProperty()
neighborhood!: string | null;
@ApiProperty()
street!: string | null;
@ApiProperty()
building!: string | null;
@ApiPropertyOptional({ type: DocumentMetaResponseDto })
profilePicture!: DocumentMetaResponseDto | null;
@ -79,8 +97,13 @@ export class CustomerResponseDto {
this.gender = customer.gender;
this.isJunior = customer.isJunior;
this.isGuardian = customer.isGuardian;
this.waitingNumber = customer.waitingNumber;
this.waitingNumber = customer.applicationNumber;
this.country = customer.country;
this.region = customer.region;
this.city = customer.city;
this.neighborhood = customer.neighborhood;
this.street = customer.street;
this.building = customer.building;
this.profilePicture = customer.profilePicture ? new DocumentMetaResponseDto(customer.profilePicture) : null;
}
}

View File

@ -5,10 +5,13 @@ import {
Entity,
Generated,
JoinColumn,
OneToMany,
OneToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { Card } from '~/card/entities';
import { CountryIso } from '~/common/enums';
import { Document } from '~/document/entities';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { Junior } from '~/junior/entities';
@ -45,7 +48,7 @@ export class Customer extends BaseEntity {
nationalIdExpiry!: Date;
@Column('varchar', { length: 255, nullable: true, name: 'country_of_residence' })
countryOfResidence!: string;
countryOfResidence!: CountryIso;
@Column('varchar', { length: 255, nullable: true, name: 'source_of_income' })
sourceOfIncome!: string;
@ -68,13 +71,31 @@ export class Customer extends BaseEntity {
@Column('boolean', { default: false, name: 'is_guardian' })
isGuardian!: boolean;
@Column('int', { name: 'waiting_number' })
@Column('int', { name: 'application_number' })
@Generated('increment')
waitingNumber!: number;
applicationNumber!: number;
@Column('varchar', { name: 'user_id' })
userId!: string;
@Column('varchar', { name: 'country', length: 255, nullable: true })
country!: CountryIso;
@Column('varchar', { name: 'region', length: 255, nullable: true })
region!: string;
@Column('varchar', { name: 'city', length: 255, nullable: true })
city!: string;
@Column('varchar', { name: 'neighborhood', length: 255, nullable: true })
neighborhood!: string;
@Column('varchar', { name: 'street', length: 255, nullable: true })
street!: string;
@Column('varchar', { name: 'building', length: 255, nullable: true })
building!: string;
@Column('varchar', { name: 'profile_picture_id', nullable: true })
profilePictureId!: string;
@ -106,9 +127,21 @@ export class Customer extends BaseEntity {
@JoinColumn({ name: 'civil_id_back_id' })
civilIdBack!: Document;
// relation ship between customer and card
@OneToMany(() => Card, (card) => card.customer)
cards!: Card[];
// relationship between cards and their parent customer
@OneToMany(() => Card, (card) => card.parentCustomer)
childCards!: Card[];
@CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP', name: 'updated_at' })
updatedAt!: Date;
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}

View File

@ -15,7 +15,7 @@ export class CustomerRepository {
findOne(where: FindOptionsWhere<Customer>) {
return this.customerRepository.findOne({
where,
relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack'],
relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack', 'cards'],
});
}

View File

@ -1,8 +1,11 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import moment from 'moment';
import { Transactional } from 'typeorm-transactional';
import { CountryIso } from '~/common/enums';
import { DocumentService, OciService } from '~/document/services';
import { GuardianService } from '~/guardian/services';
import { CreateJuniorRequestDto } from '~/junior/dtos/request';
import { User } from '~/user/entities';
import {
CreateCustomerRequestDto,
CustomerFiltersRequestDto,
@ -10,7 +13,7 @@ import {
UpdateCustomerRequestDto,
} from '../dtos/request';
import { Customer } from '../entities';
import { KycStatus } from '../enums';
import { Gender, KycStatus } from '../enums';
import { CustomerRepository } from '../repositories/customer.repository';
@Injectable()
@ -125,6 +128,33 @@ export class CustomerService {
this.logger.log(`KYC rejected for customer ${customerId}`);
}
// this function is for testing only and will be removed
@Transactional()
async updateKyc(userId: string) {
this.logger.log(`Updating KYC for customer ${userId}`);
await this.customerRepository.updateCustomer(userId, {
kycStatus: KycStatus.APPROVED,
gender: Gender.MALE,
nationalId: '1089055972',
nationalIdExpiry: moment('2031-09-17').toDate(),
countryOfResidence: CountryIso.SAUDI_ARABIA,
country: CountryIso.SAUDI_ARABIA,
region: 'Mecca',
city: 'AT Taif',
neighborhood: 'Al Faisaliah',
street: 'Al Faisaliah Street',
building: '4',
});
await User.update(userId, {
phoneNumber: this.generateSaudiPhoneNumber(),
countryCode: '+966',
});
this.logger.log(`KYC updated for customer ${userId}`);
return this.findCustomerById(userId);
}
private async validateProfilePictureForCustomer(userId: string, profilePictureId?: string) {
if (!profilePictureId) return;
@ -198,4 +228,19 @@ export class CustomerService {
customer.civilIdBack.url = civilIdBackUrl;
return customer;
}
async findAnyCustomer() {
return this.customerRepository.findOne({ isGuardian: true });
}
// TO BE REMOVED: This function is for testing only and will be removed
private generateSaudiPhoneNumber(): string {
// Saudi mobile numbers are 9 digits, always starting with '5'
const firstDigit = '5';
let rest = '';
for (let i = 0; i < 8; i++) {
rest += Math.floor(Math.random() * 10);
}
return `${firstDigit}${rest}`;
}
}

View File

@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAddressFieldsToCustomers1747569536067 implements MigrationInterface {
name = 'AddAddressFieldsToCustomers1747569536067';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "customers" ADD "country" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "region" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "city" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "neighborhood" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "street" character varying(255)`);
await queryRunner.query(`ALTER TABLE "customers" ADD "building" character varying(255)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "building"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "street"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "neighborhood"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "city"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "region"`);
await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "country"`);
}
}

View File

@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateCardEntity1749633935436 implements MigrationInterface {
name = 'CreateCardEntity1749633935436';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "cards"
("id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"card_reference" character varying NOT NULL,
"first_six_digits" character varying(6) NOT NULL,
"last_four_digits" character varying(4) NOT NULL,
"expiry" character varying NOT NULL,
"customer_type" character varying NOT NULL,
"color" character varying NOT NULL DEFAULT 'BLUE',
"status" character varying NOT NULL DEFAULT 'PENDING',
"scheme" character varying NOT NULL DEFAULT 'VISA',
"issuer" character varying NOT NULL,
"customer_id" uuid NOT NULL,
"parent_id" uuid,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_9451069b6f1199730791a7f4ae4" PRIMARY KEY ("id"))`,
);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_6fb82820c0b1b7ec32a8dbf911" ON "cards" ("card_reference") `);
await queryRunner.query(
`ALTER TABLE "cards" ADD CONSTRAINT "FK_d46837f6ab27271d8125517d0b6" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "cards" ADD CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e"`);
await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_d46837f6ab27271d8125517d0b6"`);
await queryRunner.query(`DROP INDEX "public"."IDX_6fb82820c0b1b7ec32a8dbf911"`);
await queryRunner.query(`DROP TABLE "cards"`);
}
}

View File

@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateAccountEntity1751456987627 implements MigrationInterface {
name = 'CreateAccountEntity1751456987627';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e"`);
await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_d46837f6ab27271d8125517d0b6"`);
await queryRunner.query(`DROP INDEX "public"."IDX_6fb82820c0b1b7ec32a8dbf911"`);
await queryRunner.query(
`CREATE TABLE "accounts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "account_reference" character varying(255) NOT NULL, "currency" character varying(255) NOT NULL, "balance" numeric(10,2) NOT NULL DEFAULT '0', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_829183fe026a0ce5fc8026b2417" UNIQUE ("account_reference"), CONSTRAINT "PK_5a7a02c20412299d198e097a8fe" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_829183fe026a0ce5fc8026b241" ON "accounts" ("account_reference") `,
);
await queryRunner.query(`ALTER TABLE "cards" ADD "account_id" uuid NOT NULL`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_4f7df5c5dc950295dc417a11d8" ON "cards" ("card_reference") `);
await queryRunner.query(
`ALTER TABLE "cards" ADD CONSTRAINT "FK_8ba18e7060c38ddae12e5bdf907" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "cards" ADD CONSTRAINT "FK_2fd0ee722ec57594d2e448c73d7" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "cards" ADD CONSTRAINT "FK_b2874ef49ff7da2dee49e4bc6d3" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_b2874ef49ff7da2dee49e4bc6d3"`);
await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_2fd0ee722ec57594d2e448c73d7"`);
await queryRunner.query(`ALTER TABLE "cards" DROP CONSTRAINT "FK_8ba18e7060c38ddae12e5bdf907"`);
await queryRunner.query(`DROP INDEX "public"."IDX_4f7df5c5dc950295dc417a11d8"`);
await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "account_id"`);
await queryRunner.query(`DROP INDEX "public"."IDX_829183fe026a0ce5fc8026b241"`);
await queryRunner.query(`DROP TABLE "accounts"`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_6fb82820c0b1b7ec32a8dbf911" ON "cards" ("card_reference") `);
await queryRunner.query(
`ALTER TABLE "cards" ADD CONSTRAINT "FK_d46837f6ab27271d8125517d0b6" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "cards" ADD CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
}

View File

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateTransactionTable1751466314709 implements MigrationInterface {
name = 'CreateTransactionTable1751466314709'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "transactions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "card_reference" character varying, "transaction_type" character varying NOT NULL DEFAULT 'EXTERNAL', "account_reference" character varying, "transaction_id" character varying, "card_masked_number" character varying, "transaction_date" TIMESTAMP WITH TIME ZONE, "rrn" character varying, "transaction_amount" numeric(12,2) NOT NULL, "transaction_currency" character varying NOT NULL, "billing_amount" numeric(12,2) NOT NULL, "settlement_amount" numeric(12,2) NOT NULL, "fees" numeric(12,2) NOT NULL, "card_id" uuid, "account_id" uuid, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_9162bf9ab4e31961a8f7932974c" UNIQUE ("transaction_id"), CONSTRAINT "PK_a219afd8dd77ed80f5a862f1db9" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "transactions" ADD CONSTRAINT "FK_80ad48141be648db2d84ff32f79" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "transactions" ADD CONSTRAINT "FK_49c0d6e8ba4bfb5582000d851f0" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_49c0d6e8ba4bfb5582000d851f0"`);
await queryRunner.query(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_80ad48141be648db2d84ff32f79"`);
await queryRunner.query(`DROP TABLE "transactions"`);
}
}

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class EditTransactionTable1752056898465 implements MigrationInterface {
name = 'EditTransactionTable1752056898465'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "transactions" ADD "transaction_scope" character varying NOT NULL`);
await queryRunner.query(`ALTER TABLE "transactions" ADD "vat_on_fees" numeric(12,2) NOT NULL DEFAULT '0'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "vat_on_fees"`);
await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "transaction_scope"`);
}
}

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UpdateCardTable1753098116701 implements MigrationInterface {
name = 'UpdateCardTable1753098116701'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "cards" ADD "statusDescription" character varying NOT NULL DEFAULT 'PENDING_ACTIVATION'`);
await queryRunner.query(`ALTER TABLE "cards" ADD "limit" numeric(10,2) NOT NULL DEFAULT '0'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "limit"`);
await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "statusDescription"`);
}
}

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class EditCustomerTable1753098326876 implements MigrationInterface {
name = 'EditCustomerTable1753098326876'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "customers" RENAME COLUMN "waiting_number" TO "application_number"`);
await queryRunner.query(`ALTER SEQUENCE "customers_waiting_number_seq" RENAME TO "customers_application_number_seq"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER SEQUENCE "customers_application_number_seq" RENAME TO "customers_waiting_number_seq"`);
await queryRunner.query(`ALTER TABLE "customers" RENAME COLUMN "application_number" TO "waiting_number"`);
}
}

View File

@ -24,3 +24,10 @@ export * from './1739954239949-add-civilid-to-customers-and-update-notifications
export * from './1740045960580-create-user-registration-table';
export * from './1741087742821-add-used-flag-to-otp-and-remove-constraints-from-customers';
export * from './1742112997024-update-customer-table';
export * from './1747569536067-add-address-fields-to-customers';
export * from './1749633935436-create-card-entity';
export * from './1751456987627-create-account-entity';
export * from './1751466314709-create-transaction-table';
export * from './1752056898465-edit-transaction-table';
export * from './1753098116701-update-card-table';
export * from './1753098326876-edit-customer-table';

View File

@ -43,7 +43,8 @@
},
"CUSTOMER": {
"NOT_FOUND": "لم يتم العثور على العميل.",
"ALREADY_EXISTS": "العميل موجود بالفعل."
"ALREADY_EXISTS": "العميل موجود بالفعل.",
"KYC_NOT_APPROVED": "لم يتم الموافقة على هوية العميل بعد."
},
"GIFT": {

View File

@ -42,7 +42,8 @@
},
"CUSTOMER": {
"NOT_FOUND": "The customer was not found.",
"ALREADY_EXISTS": "The customer already exists."
"ALREADY_EXISTS": "The customer already exists.",
"KYC_NOT_APPROVED": "The customer's KYC has not been approved yet."
},
"GIFT": {