From 881d88c8d8a56e513949d632f4a2495946237ba4 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 19 May 2025 14:16:18 +0300 Subject: [PATCH 01/24] feat: add customer details to customer entity --- src/common/modules/neoleap/neoleap.module.ts | 8 +++++++ src/common/modules/neoleap/services/index.ts | 0 .../neoleap/services/neoleap.service.ts | 6 +++++ src/customer/entities/customer.entity.ts | 18 +++++++++++++++ ...9536067-add-address-fields-to-customers.ts | 23 +++++++++++++++++++ src/db/migrations/index.ts | 1 + 6 files changed, 56 insertions(+) create mode 100644 src/common/modules/neoleap/neoleap.module.ts create mode 100644 src/common/modules/neoleap/services/index.ts create mode 100644 src/common/modules/neoleap/services/neoleap.service.ts create mode 100644 src/db/migrations/1747569536067-add-address-fields-to-customers.ts diff --git a/src/common/modules/neoleap/neoleap.module.ts b/src/common/modules/neoleap/neoleap.module.ts new file mode 100644 index 0000000..c68989f --- /dev/null +++ b/src/common/modules/neoleap/neoleap.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; + +@Module({ + imports: [], + controllers: [], + providers: [], +}) +export class NeoLeapModule {} diff --git a/src/common/modules/neoleap/services/index.ts b/src/common/modules/neoleap/services/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts new file mode 100644 index 0000000..f172937 --- /dev/null +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class NeoLeapService { + createApplication() {} +} diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index 48be217..0dd917d 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -75,6 +75,24 @@ export class Customer extends BaseEntity { @Column('varchar', { name: 'user_id' }) userId!: string; + @Column('varchar', { name: 'country', length: 255, nullable: true }) + country!: string; + + @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; diff --git a/src/db/migrations/1747569536067-add-address-fields-to-customers.ts b/src/db/migrations/1747569536067-add-address-fields-to-customers.ts new file mode 100644 index 0000000..4d8a8ec --- /dev/null +++ b/src/db/migrations/1747569536067-add-address-fields-to-customers.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAddressFieldsToCustomers1747569536067 implements MigrationInterface { + name = 'AddAddressFieldsToCustomers1747569536067'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 9ca8914..3ad20d0 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -24,3 +24,4 @@ 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'; From a358cd2e7ac5be4686175e7ae3913952d3cea527 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 26 May 2025 12:04:00 +0300 Subject: [PATCH 02/24] feat: add neoleap service and mock create application api --- .../dtos/request/verify-user.request.dto.ts | 19 +- .../countries-numeric-iso.constant.ts | 253 ++++++++++++++++++ src/common/constants/index.ts | 1 + src/common/enums/countries-iso.enum.ts | 251 +++++++++++++++++ src/common/enums/index.ts | 1 + .../create-application.request.interface.ts | 52 ++++ .../modules/neoleap/interfaces/index.ts | 2 + .../neoleap-header.request.interface.ts | 9 + .../neoleap/services/neoleap.service.ts | 97 ++++++- .../request/create-customer.request.dto.ts | 8 +- src/customer/entities/customer.entity.ts | 3 +- .../repositories/customer.repository.ts | 1 + 12 files changed, 687 insertions(+), 10 deletions(-) create mode 100644 src/common/constants/countries-numeric-iso.constant.ts create mode 100644 src/common/enums/countries-iso.enum.ts create mode 100644 src/common/enums/index.ts create mode 100644 src/common/modules/neoleap/interfaces/create-application.request.interface.ts create mode 100644 src/common/modules/neoleap/interfaces/index.ts create mode 100644 src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts diff --git a/src/auth/dtos/request/verify-user.request.dto.ts b/src/auth/dtos/request/verify-user.request.dto.ts index cb5c74f..c1adc87 100644 --- a/src/auth/dtos/request/verify-user.request.dto.ts +++ b/src/auth/dtos/request/verify-user.request.dto.ts @@ -1,6 +1,16 @@ import { ApiProperty, PickType } from '@nestjs/swagger'; -import { IsDateString, IsNotEmpty, IsNumberString, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { + IsDateString, + IsEnum, + IsNotEmpty, + IsNumberString, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { CountryIso } from '~/common/enums'; import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; import { IsAbove18 } from '~/core/decorators/validations'; import { CreateUnverifiedUserRequestDto } from './create-unverified-user.request.dto'; @@ -22,10 +32,11 @@ export class VerifyUserRequestDto extends PickType(CreateUnverifiedUserRequestDt dateOfBirth!: Date; @ApiProperty({ example: 'JO' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.countryOfResidence' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.countryOfResidence' }) }) + @IsEnum(CountryIso, { + message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }), + }) @IsOptional() - countryOfResidence: string = 'SA'; + countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA; @ApiProperty({ example: '111111' }) @IsNumberString( diff --git a/src/common/constants/countries-numeric-iso.constant.ts b/src/common/constants/countries-numeric-iso.constant.ts new file mode 100644 index 0000000..dc45cec --- /dev/null +++ b/src/common/constants/countries-numeric-iso.constant.ts @@ -0,0 +1,253 @@ +import { CountryIso } from '../enums'; + +export const CountriesNumericISO: Record = { + [CountryIso.ARUBA]: '533', + [CountryIso.AFGHANISTAN]: '004', + [CountryIso.ANGOLA]: '024', + [CountryIso.ANGUILLA]: '660', + [CountryIso.ALAND_ISLANDS]: '248', + [CountryIso.ALBANIA]: '008', + [CountryIso.ANDORRA]: '020', + [CountryIso.UNITED_ARAB_EMIRATES]: '784', + [CountryIso.ARGENTINA]: '032', + [CountryIso.ARMENIA]: '051', + [CountryIso.AMERICAN_SAMOA]: '016', + [CountryIso.ANTARCTICA]: '010', + [CountryIso.FRENCH_SOUTHERN_TERRITORIES]: '260', + [CountryIso.ANTIGUA_AND_BARBUDA]: '028', + [CountryIso.AUSTRALIA]: '036', + [CountryIso.AUSTRIA]: '040', + [CountryIso.AZERBAIJAN]: '031', + [CountryIso.BURUNDI]: '108', + [CountryIso.BELGIUM]: '056', + [CountryIso.BENIN]: '204', + [CountryIso.BONAIRE_SINT_EUSTATIUS_AND_SABA]: '535', + [CountryIso.BURKINA_FASO]: '854', + [CountryIso.BANGLADESH]: '050', + [CountryIso.BULGARIA]: '100', + [CountryIso.BAHRAIN]: '048', + [CountryIso.BAHAMAS]: '044', + [CountryIso.BOSNIA_AND_HERZEGOVINA]: '070', + [CountryIso.SAINT_BARTHÉLEMY]: '652', + [CountryIso.BELARUS]: '112', + [CountryIso.BELIZE]: '084', + [CountryIso.BERMUDA]: '060', + [CountryIso.BOLIVIA_PLURINATIONAL_STATE_OF]: '068', + [CountryIso.BRAZIL]: '076', + [CountryIso.BARBADOS]: '052', + [CountryIso.BRUNEI_DARUSSALAM]: '096', + [CountryIso.BHUTAN]: '064', + [CountryIso.BOUVET_ISLAND]: '074', + [CountryIso.BOTSWANA]: '072', + [CountryIso.CENTRAL_AFRICAN_REPUBLIC]: '140', + [CountryIso.CANADA]: '124', + [CountryIso.COCOS_KEELING_ISLANDS]: '166', + [CountryIso.SWITZERLAND]: '756', + [CountryIso.CHILE]: '152', + [CountryIso.CHINA]: '156', + [CountryIso.COTE_DIVOIRE]: '384', + [CountryIso.CAMEROON]: '120', + [CountryIso.CONGO_THE_DEMOCRATIC_REPUBLIC_OF_THE]: '180', + [CountryIso.CONGO]: '178', + [CountryIso.COOK_ISLANDS]: '184', + [CountryIso.COLOMBIA]: '170', + [CountryIso.COMOROS]: '174', + [CountryIso.CABO_VERDE]: '132', + [CountryIso.COSTA_RICA]: '188', + [CountryIso.CUBA]: '192', + [CountryIso.CURAÇAO]: '531', + [CountryIso.CHRISTMAS_ISLAND]: '162', + [CountryIso.CAYMAN_ISLANDS]: '136', + [CountryIso.CYPRUS]: '196', + [CountryIso.CZECHIA]: '203', + [CountryIso.GERMANY]: '276', + [CountryIso.DJIBOUTI]: '262', + [CountryIso.DOMINICA]: '212', + [CountryIso.DENMARK]: '208', + [CountryIso.DOMINICAN_REPUBLIC]: '214', + [CountryIso.ALGERIA]: '012', + [CountryIso.ECUADOR]: '218', + [CountryIso.EGYPT]: '818', + [CountryIso.ERITREA]: '232', + [CountryIso.WESTERN_SAHARA]: '732', + [CountryIso.SPAIN]: '724', + [CountryIso.ESTONIA]: '233', + [CountryIso.ETHIOPIA]: '231', + [CountryIso.FINLAND]: '246', + [CountryIso.FIJI]: '242', + [CountryIso.FALKLAND_ISLANDS_MALVINAS]: '238', + [CountryIso.FRANCE]: '250', + [CountryIso.FAROE_ISLANDS]: '234', + [CountryIso.MICRONESIA_FEDERATED_STATES_OF]: '583', + [CountryIso.GABON]: '266', + [CountryIso.UNITED_KINGDOM]: '826', + [CountryIso.GEORGIA]: '268', + [CountryIso.GUERNSEY]: '831', + [CountryIso.GHANA]: '288', + [CountryIso.GIBRALTAR]: '292', + [CountryIso.GUINEA]: '324', + [CountryIso.GUADELOUPE]: '312', + [CountryIso.GAMBIA]: '270', + [CountryIso.GUINEA_BISSAU]: '624', + [CountryIso.EQUATORIAL_GUINEA]: '226', + [CountryIso.GREECE]: '300', + [CountryIso.GRENADA]: '308', + [CountryIso.GREENLAND]: '304', + [CountryIso.GUATEMALA]: '320', + [CountryIso.FRENCH_GUIANA]: '254', + [CountryIso.GUAM]: '316', + [CountryIso.GUYANA]: '328', + [CountryIso.HONG_KONG]: '344', + [CountryIso.HEARD_ISLAND_AND_MCDONALD_ISLANDS]: '334', + [CountryIso.HONDURAS]: '340', + [CountryIso.CROATIA]: '191', + [CountryIso.HAITI]: '332', + [CountryIso.HUNGARY]: '348', + [CountryIso.INDONESIA]: '360', + [CountryIso.ISLE_OF_MAN]: '833', + [CountryIso.INDIA]: '356', + [CountryIso.BRITISH_INDIAN_OCEAN_TERRITORY]: '086', + [CountryIso.IRELAND]: '372', + [CountryIso.IRAN_ISLAMIC_REPUBLIC_OF]: '364', + [CountryIso.IRAQ]: '368', + [CountryIso.ICELAND]: '352', + [CountryIso.ISRAEL]: '376', + [CountryIso.ITALY]: '380', + [CountryIso.JAMAICA]: '388', + [CountryIso.JERSEY]: '832', + [CountryIso.JORDAN]: '400', + [CountryIso.JAPAN]: '392', + [CountryIso.KAZAKHSTAN]: '398', + [CountryIso.KENYA]: '404', + [CountryIso.KYRGYZSTAN]: '417', + [CountryIso.CAMBODIA]: '116', + [CountryIso.KIRIBATI]: '296', + [CountryIso.SAINT_KITTS_AND_NEVIS]: '659', + [CountryIso.KOREA_REPUBLIC_OF]: '410', + [CountryIso.KUWAIT]: '414', + [CountryIso.LAO_PEOPLES_DEMOCRATIC_REPUBLIC]: '418', + [CountryIso.LEBANON]: '422', + [CountryIso.LIBERIA]: '430', + [CountryIso.LIBYA]: '434', + [CountryIso.SAINT_LUCIA]: '662', + [CountryIso.LIECHTENSTEIN]: '438', + [CountryIso.SRI_LANKA]: '144', + [CountryIso.LESOTHO]: '426', + [CountryIso.LITHUANIA]: '440', + [CountryIso.LUXEMBOURG]: '442', + [CountryIso.LATVIA]: '428', + [CountryIso.MACAO]: '446', + [CountryIso.SAINT_MARTIN_FRENCH_PART]: '663', + [CountryIso.MOROCCO]: '504', + [CountryIso.MONACO]: '492', + [CountryIso.MOLDOVA_REPUBLIC_OF]: '498', + [CountryIso.MADAGASCAR]: '450', + [CountryIso.MALDIVES]: '462', + [CountryIso.MEXICO]: '484', + [CountryIso.MARSHALL_ISLANDS]: '584', + [CountryIso.NORTH_MACEDONIA]: '807', + [CountryIso.MALI]: '466', + [CountryIso.MALTA]: '470', + [CountryIso.MYANMAR]: '104', + [CountryIso.MONTENEGRO]: '499', + [CountryIso.MONGOLIA]: '496', + [CountryIso.NORTHERN_MARIANA_ISLANDS]: '580', + [CountryIso.MOZAMBIQUE]: '508', + [CountryIso.MAURITANIA]: '478', + [CountryIso.MONTSERRAT]: '500', + [CountryIso.MARTINIQUE]: '474', + [CountryIso.MAURITIUS]: '480', + [CountryIso.MALAWI]: '454', + [CountryIso.MALAYSIA]: '458', + [CountryIso.MAYOTTE]: '175', + [CountryIso.NAMIBIA]: '516', + [CountryIso.NEW_CALEDONIA]: '540', + [CountryIso.NIGER]: '562', + [CountryIso.NORFOLK_ISLAND]: '574', + [CountryIso.NIGERIA]: '566', + [CountryIso.NICARAGUA]: '558', + [CountryIso.NIUE]: '570', + [CountryIso.NETHERLANDS]: '528', + [CountryIso.NORWAY]: '578', + [CountryIso.NEPAL]: '524', + [CountryIso.NAURU]: '520', + [CountryIso.NEW_ZEALAND]: '554', + [CountryIso.OMAN]: '512', + [CountryIso.PAKISTAN]: '586', + [CountryIso.PANAMA]: '591', + [CountryIso.PITCAIRN]: '612', + [CountryIso.PERU]: '604', + [CountryIso.PHILIPPINES]: '608', + [CountryIso.PALAU]: '585', + [CountryIso.PAPUA_NEW_GUINEA]: '598', + [CountryIso.POLAND]: '616', + [CountryIso.PUERTO_RICO]: '630', + [CountryIso.KOREA_DEMOCRATIC_PEOPLES_REPUBLIC_OF]: '408', + [CountryIso.PORTUGAL]: '620', + [CountryIso.PARAGUAY]: '600', + [CountryIso.PALESTINE_STATE_OF]: '275', + [CountryIso.FRENCH_POLYNESIA]: '258', + [CountryIso.QATAR]: '634', + [CountryIso.REUNION]: '638', + [CountryIso.ROMANIA]: '642', + [CountryIso.RUSSIAN_FEDERATION]: '643', + [CountryIso.RWANDA]: '646', + [CountryIso.SAUDI_ARABIA]: '682', + [CountryIso.SUDAN]: '729', + [CountryIso.SENEGAL]: '686', + [CountryIso.SINGAPORE]: '702', + [CountryIso.SOUTH_GEORGIA_AND_THE_SOUTH_SANDWICH_ISLANDS]: '239', + [CountryIso.SAINT_HELENA_ASCENSION_AND_TRISTAN_DA_CUNHA]: '654', + [CountryIso.SVALBARD_AND_JAN_MAYEN]: '744', + [CountryIso.SOLOMON_ISLANDS]: '090', + [CountryIso.SIERRA_LEONE]: '694', + [CountryIso.EL_SALVADOR]: '222', + [CountryIso.SAN_MARINO]: '674', + [CountryIso.SOMALIA]: '706', + [CountryIso.SAINT_PIERRE_AND_MIQUELON]: '666', + [CountryIso.SERBIA]: '688', + [CountryIso.SOUTH_SUDAN]: '728', + [CountryIso.SAO_TOME_AND_PRINCIPE]: '678', + [CountryIso.SURINAME]: '740', + [CountryIso.SLOVAKIA]: '703', + [CountryIso.SLOVENIA]: '705', + [CountryIso.SWEDEN]: '752', + [CountryIso.ESWATINI]: '748', + [CountryIso.SINT_MAARTEN_DUTCH_PART]: '534', + [CountryIso.SEYCHELLES]: '690', + [CountryIso.SYRIAN_ARAB_REPUBLIC]: '760', + [CountryIso.TURKS_AND_CAICOS_ISLANDS]: '796', + [CountryIso.CHAD]: '148', + [CountryIso.TOGO]: '768', + [CountryIso.THAILAND]: '764', + [CountryIso.TAJIKISTAN]: '762', + [CountryIso.TOKELAU]: '772', + [CountryIso.TURKMENISTAN]: '795', + [CountryIso.TIMOR_LESTE]: '626', + [CountryIso.TONGA]: '776', + [CountryIso.TRINIDAD_AND_TOBAGO]: '780', + [CountryIso.TUNISIA]: '788', + [CountryIso.TURKEY]: '792', + [CountryIso.TUVALU]: '798', + [CountryIso.TAIWAN_PROVINCE_OF_CHINA]: '158', + [CountryIso.TANZANIA_UNITED_REPUBLIC_OF]: '834', + [CountryIso.UGANDA]: '800', + [CountryIso.UKRAINE]: '804', + [CountryIso.UNITED_STATES_MINOR_OUTLYING_ISLANDS]: '581', + [CountryIso.URUGUAY]: '858', + [CountryIso.UNITED_STATES]: '840', + [CountryIso.UZBEKISTAN]: '860', + [CountryIso.HOLY_SEE_VATICAN_CITY_STATE]: '336', + [CountryIso.SAINT_VINCENT_AND_THE_GRENADINES]: '670', + [CountryIso.VENEZUELA_BOLIVARIAN_REPUBLIC_OF]: '862', + [CountryIso.VIRGIN_ISLANDS_BRITISH]: '092', + [CountryIso.VIRGIN_ISLANDS_US]: '850', + [CountryIso.VIET_NAM]: '704', + [CountryIso.VANUATU]: '548', + [CountryIso.WALLIS_AND_FUTUNA]: '876', + [CountryIso.SAMOA]: '882', + [CountryIso.YEMEN]: '887', + [CountryIso.SOUTH_AFRICA]: '710', + [CountryIso.ZAMBIA]: '894', + [CountryIso.ZIMBABWE]: '716', +}; diff --git a/src/common/constants/index.ts b/src/common/constants/index.ts index c1f9866..ad8d1dc 100644 --- a/src/common/constants/index.ts +++ b/src/common/constants/index.ts @@ -1 +1,2 @@ +export * from './countries-numeric-iso.constant'; export * from './global.constant'; diff --git a/src/common/enums/countries-iso.enum.ts b/src/common/enums/countries-iso.enum.ts new file mode 100644 index 0000000..c996051 --- /dev/null +++ b/src/common/enums/countries-iso.enum.ts @@ -0,0 +1,251 @@ +export enum CountryIso { + ARUBA = 'AW', + AFGHANISTAN = 'AF', + ANGOLA = 'AO', + ANGUILLA = 'AI', + ALAND_ISLANDS = 'AX', + ALBANIA = 'AL', + ANDORRA = 'AD', + UNITED_ARAB_EMIRATES = 'AE', + ARGENTINA = 'AR', + ARMENIA = 'AM', + AMERICAN_SAMOA = 'AS', + ANTARCTICA = 'AQ', + FRENCH_SOUTHERN_TERRITORIES = 'TF', + ANTIGUA_AND_BARBUDA = 'AG', + AUSTRALIA = 'AU', + AUSTRIA = 'AT', + AZERBAIJAN = 'AZ', + BURUNDI = 'BI', + BELGIUM = 'BE', + BENIN = 'BJ', + BONAIRE_SINT_EUSTATIUS_AND_SABA = 'BQ', + BURKINA_FASO = 'BF', + BANGLADESH = 'BD', + BULGARIA = 'BG', + BAHRAIN = 'BH', + BAHAMAS = 'BS', + BOSNIA_AND_HERZEGOVINA = 'BA', + SAINT_BARTHÉLEMY = 'BL', + BELARUS = 'BY', + BELIZE = 'BZ', + BERMUDA = 'BM', + BOLIVIA_PLURINATIONAL_STATE_OF = 'BO', + BRAZIL = 'BR', + BARBADOS = 'BB', + BRUNEI_DARUSSALAM = 'BN', + BHUTAN = 'BT', + BOUVET_ISLAND = 'BV', + BOTSWANA = 'BW', + CENTRAL_AFRICAN_REPUBLIC = 'CF', + CANADA = 'CA', + COCOS_KEELING_ISLANDS = 'CC', + SWITZERLAND = 'CH', + CHILE = 'CL', + CHINA = 'CN', + COTE_DIVOIRE = 'CI', + CAMEROON = 'CM', + CONGO_THE_DEMOCRATIC_REPUBLIC_OF_THE = 'CD', + CONGO = 'CG', + COOK_ISLANDS = 'CK', + COLOMBIA = 'CO', + COMOROS = 'KM', + CABO_VERDE = 'CV', + COSTA_RICA = 'CR', + CUBA = 'CU', + CURAÇAO = 'CW', + CHRISTMAS_ISLAND = 'CX', + CAYMAN_ISLANDS = 'KY', + CYPRUS = 'CY', + CZECHIA = 'CZ', + GERMANY = 'DE', + DJIBOUTI = 'DJ', + DOMINICA = 'DM', + DENMARK = 'DK', + DOMINICAN_REPUBLIC = 'DO', + ALGERIA = 'DZ', + ECUADOR = 'EC', + EGYPT = 'EG', + ERITREA = 'ER', + WESTERN_SAHARA = 'EH', + SPAIN = 'ES', + ESTONIA = 'EE', + ETHIOPIA = 'ET', + FINLAND = 'FI', + FIJI = 'FJ', + FALKLAND_ISLANDS_MALVINAS = 'FK', + FRANCE = 'FR', + FAROE_ISLANDS = 'FO', + MICRONESIA_FEDERATED_STATES_OF = 'FM', + GABON = 'GA', + UNITED_KINGDOM = 'GB', + GEORGIA = 'GE', + GUERNSEY = 'GG', + GHANA = 'GH', + GIBRALTAR = 'GI', + GUINEA = 'GN', + GUADELOUPE = 'GP', + GAMBIA = 'GM', + GUINEA_BISSAU = 'GW', + EQUATORIAL_GUINEA = 'GQ', + GREECE = 'GR', + GRENADA = 'GD', + GREENLAND = 'GL', + GUATEMALA = 'GT', + FRENCH_GUIANA = 'GF', + GUAM = 'GU', + GUYANA = 'GY', + HONG_KONG = 'HK', + HEARD_ISLAND_AND_MCDONALD_ISLANDS = 'HM', + HONDURAS = 'HN', + CROATIA = 'HR', + HAITI = 'HT', + HUNGARY = 'HU', + INDONESIA = 'ID', + ISLE_OF_MAN = 'IM', + INDIA = 'IN', + BRITISH_INDIAN_OCEAN_TERRITORY = 'IO', + IRELAND = 'IE', + IRAN_ISLAMIC_REPUBLIC_OF = 'IR', + IRAQ = 'IQ', + ICELAND = 'IS', + ISRAEL = 'IL', + ITALY = 'IT', + JAMAICA = 'JM', + JERSEY = 'JE', + JORDAN = 'JO', + JAPAN = 'JP', + KAZAKHSTAN = 'KZ', + KENYA = 'KE', + KYRGYZSTAN = 'KG', + CAMBODIA = 'KH', + KIRIBATI = 'KI', + SAINT_KITTS_AND_NEVIS = 'KN', + KOREA_REPUBLIC_OF = 'KR', + KUWAIT = 'KW', + LAO_PEOPLES_DEMOCRATIC_REPUBLIC = 'LA', + LEBANON = 'LB', + LIBERIA = 'LR', + LIBYA = 'LY', + SAINT_LUCIA = 'LC', + LIECHTENSTEIN = 'LI', + SRI_LANKA = 'LK', + LESOTHO = 'LS', + LITHUANIA = 'LT', + LUXEMBOURG = 'LU', + LATVIA = 'LV', + MACAO = 'MO', + SAINT_MARTIN_FRENCH_PART = 'MF', + MOROCCO = 'MA', + MONACO = 'MC', + MOLDOVA_REPUBLIC_OF = 'MD', + MADAGASCAR = 'MG', + MALDIVES = 'MV', + MEXICO = 'MX', + MARSHALL_ISLANDS = 'MH', + NORTH_MACEDONIA = 'MK', + MALI = 'ML', + MALTA = 'MT', + MYANMAR = 'MM', + MONTENEGRO = 'ME', + MONGOLIA = 'MN', + NORTHERN_MARIANA_ISLANDS = 'MP', + MOZAMBIQUE = 'MZ', + MAURITANIA = 'MR', + MONTSERRAT = 'MS', + MARTINIQUE = 'MQ', + MAURITIUS = 'MU', + MALAWI = 'MW', + MALAYSIA = 'MY', + MAYOTTE = 'YT', + NAMIBIA = 'NA', + NEW_CALEDONIA = 'NC', + NIGER = 'NE', + NORFOLK_ISLAND = 'NF', + NIGERIA = 'NG', + NICARAGUA = 'NI', + NIUE = 'NU', + NETHERLANDS = 'NL', + NORWAY = 'NO', + NEPAL = 'NP', + NAURU = 'NR', + NEW_ZEALAND = 'NZ', + OMAN = 'OM', + PAKISTAN = 'PK', + PANAMA = 'PA', + PITCAIRN = 'PN', + PERU = 'PE', + PHILIPPINES = 'PH', + PALAU = 'PW', + PAPUA_NEW_GUINEA = 'PG', + POLAND = 'PL', + PUERTO_RICO = 'PR', + KOREA_DEMOCRATIC_PEOPLES_REPUBLIC_OF = 'KP', + PORTUGAL = 'PT', + PARAGUAY = 'PY', + PALESTINE_STATE_OF = 'PS', + FRENCH_POLYNESIA = 'PF', + QATAR = 'QA', + REUNION = 'RE', + ROMANIA = 'RO', + RUSSIAN_FEDERATION = 'RU', + RWANDA = 'RW', + SAUDI_ARABIA = 'SA', + SUDAN = 'SD', + SENEGAL = 'SN', + SINGAPORE = 'SG', + SOUTH_GEORGIA_AND_THE_SOUTH_SANDWICH_ISLANDS = 'GS', + SAINT_HELENA_ASCENSION_AND_TRISTAN_DA_CUNHA = 'SH', + SVALBARD_AND_JAN_MAYEN = 'SJ', + SOLOMON_ISLANDS = 'SB', + SIERRA_LEONE = 'SL', + EL_SALVADOR = 'SV', + SAN_MARINO = 'SM', + SOMALIA = 'SO', + SAINT_PIERRE_AND_MIQUELON = 'PM', + SERBIA = 'RS', + SOUTH_SUDAN = 'SS', + SAO_TOME_AND_PRINCIPE = 'ST', + SURINAME = 'SR', + SLOVAKIA = 'SK', + SLOVENIA = 'SI', + SWEDEN = 'SE', + ESWATINI = 'SZ', + SINT_MAARTEN_DUTCH_PART = 'SX', + SEYCHELLES = 'SC', + SYRIAN_ARAB_REPUBLIC = 'SY', + TURKS_AND_CAICOS_ISLANDS = 'TC', + CHAD = 'TD', + TOGO = 'TG', + THAILAND = 'TH', + TAJIKISTAN = 'TJ', + TOKELAU = 'TK', + TURKMENISTAN = 'TM', + TIMOR_LESTE = 'TL', + TONGA = 'TO', + TRINIDAD_AND_TOBAGO = 'TT', + TUNISIA = 'TN', + TURKEY = 'TR', + TUVALU = 'TV', + TAIWAN_PROVINCE_OF_CHINA = 'TW', + TANZANIA_UNITED_REPUBLIC_OF = 'TZ', + UGANDA = 'UG', + UKRAINE = 'UA', + UNITED_STATES_MINOR_OUTLYING_ISLANDS = 'UM', + URUGUAY = 'UY', + UNITED_STATES = 'US', + UZBEKISTAN = 'UZ', + HOLY_SEE_VATICAN_CITY_STATE = 'VA', + SAINT_VINCENT_AND_THE_GRENADINES = 'VC', + VENEZUELA_BOLIVARIAN_REPUBLIC_OF = 'VE', + VIRGIN_ISLANDS_BRITISH = 'VG', + VIRGIN_ISLANDS_US = 'VI', + VIET_NAM = 'VN', + VANUATU = 'VU', + WALLIS_AND_FUTUNA = 'WF', + SAMOA = 'WS', + YEMEN = 'YE', + SOUTH_AFRICA = 'ZA', + ZAMBIA = 'ZM', + ZIMBABWE = 'ZW', +} diff --git a/src/common/enums/index.ts b/src/common/enums/index.ts new file mode 100644 index 0000000..f5c60b9 --- /dev/null +++ b/src/common/enums/index.ts @@ -0,0 +1 @@ +export * from './countries-iso.enum'; diff --git a/src/common/modules/neoleap/interfaces/create-application.request.interface.ts b/src/common/modules/neoleap/interfaces/create-application.request.interface.ts new file mode 100644 index 0000000..9b26632 --- /dev/null +++ b/src/common/modules/neoleap/interfaces/create-application.request.interface.ts @@ -0,0 +1,52 @@ +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; + }; + + 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; + }; + }; +} diff --git a/src/common/modules/neoleap/interfaces/index.ts b/src/common/modules/neoleap/interfaces/index.ts new file mode 100644 index 0000000..97472ef --- /dev/null +++ b/src/common/modules/neoleap/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './create-application.request.interface'; +export * from './neoleap-header.request.interface'; diff --git a/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts b/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts new file mode 100644 index 0000000..5e106f4 --- /dev/null +++ b/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts @@ -0,0 +1,9 @@ +export interface INeoleapHeaderRequest { + RequestHeader: { + Version: string; + MsgUid: string; + Source: 'FINTECH'; + ServiceId: string; + ReqDateTime: Date; + }; +} diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index f172937..ac10e07 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -1,6 +1,99 @@ +import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; - +import { ConfigService } from '@nestjs/config'; +import moment from 'moment'; +import { v4 as uuid } from 'uuid'; +import { CountriesNumericISO } from '~/common/constants'; +import { Customer } from '~/customer/entities'; +import { Gender } from '~/customer/enums'; +import { ICreateApplicationRequest, INeoleapHeaderRequest } from '../interfaces'; @Injectable() export class NeoLeapService { - createApplication() {} + private readonly baseUrl: string; + private readonly apiKey: string; + private readonly useMock: boolean; + private readonly institutionCode = '1100'; + constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { + this.baseUrl = this.configService.getOrThrow('NEOLEAP_BASE_URL'); + this.apiKey = this.configService.getOrThrow('NEOLEAP_API_KEY'); + this.useMock = [true, 'true'].includes(this.configService.get('USE_MOCK', false)); + } + + async createApplication(customer: Customer) { + if (this.useMock) { + //@TODO return mock data + return; + } + + const payload: ICreateApplicationRequest = { + CreateNewApplicationRequestDetails: { + ApplicationRequestDetails: { + InstitutionCode: this.institutionCode, + ExternalApplicationNumber: customer.waitingNumber.toString(), + ApplicationType: 'New', + Product: '3001', + ApplicationDate: moment().format('YYYY-MM-DD'), + BranchCode: '000', + ApplicationSource: 'O', + DeliveryMethod: 'V', + }, + ApplicationProcessingDetails: { + SuggestedLimit: 0, + RequestedLimit: 0, + AssignedLimit: 0, + ProcessControl: 'STND', + }, + ApplicationCustomerDetails: { + FirstName: customer.firstName, + LastName: customer.lastName, + FullName: `${customer.firstName} ${customer.lastName}`, + DateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'), + EmbossName: `${customer.firstName} ${customer.lastName}`, // TODO Enter Emboss Name + IdType: '001', + 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: '682', + }, + ApplicationAddress: { + City: customer.city, + Country: CountriesNumericISO[customer.countryOfResidence], + Region: customer.region, + AddressLine1: `${customer.street} ${customer.building}`, + AddressLine2: customer.neighborhood, + AddressRole: 0, + Email: customer.user.email, + Phone1: customer.user.phoneNumber, + CountryDetails: { + DefaultCurrency: {}, + Description: [], + }, + }, + }, + ...this.prepareHeaders('CreateNewApplication'), + }; + + const response = await this.httpService.axiosRef.post(`${this.baseUrl}/create-application`, payload, { + headers: { + 'Content-Type': 'application/json', + Authorization: `${this.apiKey}`, + }, + }); + + //@TODO handle response + } + + private prepareHeaders(serviceName: string): INeoleapHeaderRequest { + return { + RequestHeader: { + Version: '1.0.0', + MsgUid: uuid(), + Source: 'FINTECH', + ServiceId: serviceName, + ReqDateTime: new Date(), + }, + }; + } } diff --git a/src/customer/dtos/request/create-customer.request.dto.ts b/src/customer/dtos/request/create-customer.request.dto.ts index 55e5023..83cc614 100644 --- a/src/customer/dtos/request/create-customer.request.dto.ts +++ b/src/customer/dtos/request/create-customer.request.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsBoolean, IsDateString, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { CountryIso } from '~/common/enums'; import { IsAbove18 } from '~/core/decorators/validations'; import { Gender } from '~/customer/enums'; export class CreateCustomerRequestDto { @@ -20,9 +21,10 @@ export class CreateCustomerRequestDto { gender?: Gender; @ApiProperty({ example: 'JO' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.countryOfResidence' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.countryOfResidence' }) }) - countryOfResidence!: string; + @IsEnum(CountryIso, { + message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }), + }) + countryOfResidence!: CountryIso; @ApiProperty({ example: '2021-01-01' }) @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index 0dd917d..01230ae 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -9,6 +9,7 @@ import { PrimaryColumn, UpdateDateColumn, } from 'typeorm'; +import { CountryIso } from '~/common/enums'; import { Document } from '~/document/entities'; import { Guardian } from '~/guardian/entities/guradian.entity'; import { Junior } from '~/junior/entities'; @@ -45,7 +46,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; diff --git a/src/customer/repositories/customer.repository.ts b/src/customer/repositories/customer.repository.ts index 100348f..a17b383 100644 --- a/src/customer/repositories/customer.repository.ts +++ b/src/customer/repositories/customer.repository.ts @@ -29,6 +29,7 @@ export class CustomerRepository { firstName: body.firstName, lastName: body.lastName, dateOfBirth: body.dateOfBirth, + countryOfResidence: body.countryOfResidence, }), ); } From d4fe3b3fc3c7042216e12767bdb79b0f8a7f10dd Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 26 May 2025 16:34:09 +0300 Subject: [PATCH 03/24] feat: finish working on mocking inquire application api --- src/app.module.ts | 2 + src/common/modules/neoleap/__mocks__/index.ts | 1 + .../__mocks__/inquire-application.mock.ts | 91 ++++++++++++ .../neoleap/controllers/neotest.controller.ts | 14 ++ .../modules/neoleap/dtos/response/index.ts | 1 + .../response/inquire-application.response.ts | 133 ++++++++++++++++++ .../modules/neoleap/interfaces/index.ts | 1 + .../inquire-application.request.interface.ts | 22 +++ src/common/modules/neoleap/neoleap.module.ts | 9 +- .../neoleap/services/neoleap.service.ts | 49 ++++++- 10 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 src/common/modules/neoleap/__mocks__/index.ts create mode 100644 src/common/modules/neoleap/__mocks__/inquire-application.mock.ts create mode 100644 src/common/modules/neoleap/controllers/neotest.controller.ts create mode 100644 src/common/modules/neoleap/dtos/response/index.ts create mode 100644 src/common/modules/neoleap/dtos/response/inquire-application.response.ts create mode 100644 src/common/modules/neoleap/interfaces/inquire-application.request.interface.ts diff --git a/src/app.module.ts b/src/app.module.ts index 4c2b94b..8b9bc17 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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'; @@ -80,6 +81,7 @@ import { UserModule } from './user/user.module'; UserModule, CronModule, + NeoLeapModule, ], providers: [ // Global Pipes diff --git a/src/common/modules/neoleap/__mocks__/index.ts b/src/common/modules/neoleap/__mocks__/index.ts new file mode 100644 index 0000000..653007b --- /dev/null +++ b/src/common/modules/neoleap/__mocks__/index.ts @@ -0,0 +1 @@ +export * from './inquire-application.mock'; diff --git a/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts b/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts new file mode 100644 index 0000000..3a74ca7 --- /dev/null +++ b/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts @@ -0,0 +1,91 @@ +export const INQUIRE_APPLICATION_MOCK = { + ResponseHeader: { + Version: '1.0.0', + MsgUid: 'stringstringstringstringstringstring', + Source: 'string', + ServiceId: 'string', + ReqDateTime: '2025-05-26T10:23:13.812Z', + RspDateTime: '2025-05-26T10:23:13.812Z', + ResponseCode: 'string', + ResponseType: 'Validation Error', + ProcessingTime: 0, + EncryptionKey: 'string', + ResponseDescription: 'string', + LocalizedResponseDescription: { + Locale: 'string', + LocalizedDescription: 'string', + }, + CustomerSpecificResponseDescriptionList: [ + { + Locale: 'string', + ResponseDescription: 'string', + }, + ], + HeaderUserDataList: [ + { + Tag: 'string', + Value: 'string', + }, + ], + }, + + InquireApplicationResponseDetails: { + InstitutionCode: 'stri', + ApplicationTypeDetails: { + TypeCode: 'st', + Description: 'string', + Additional: true, + Corporate: true, + UserData: 'string', + }, + ApplicationDetails: { + ApplicationNumber: 'string', + ExternalApplicationNumber: 'string', + ApplicationStatus: 'st', + Organization: 0, + Product: 'stri', + ApplicatonDate: '2025-05-26', + ApplicationSource: 's', + SalesSource: 'string', + DeliveryMethod: 's', + ProgramCode: 'string', + Campaign: 'string', + Plastic: 'string', + Design: 'string', + ProcessStage: 'st', + ProcessStageStatus: 's', + Score: 'string', + ExternalScore: 'string', + RequestedLimit: 0, + SuggestedLimit: 0, + AssignedLimit: 0, + AllowedLimitList: [ + { + CreditLimit: 0, + EvaluationCode: 'string', + }, + ], + EligibilityCheckResult: 'string', + EligibilityCheckDescription: 'string', + Title: 'string', + FirstName: 'string', + SecondName: 'string', + ThirdName: 'string', + LastName: 'string', + FullName: 'string', + EmbossName: 'string', + PlaceOfBirth: 'string', + DateOfBirth: '2025-05-26', + LocalizedDateOfBirth: '2025-05-26', + Age: 20, + Gender: 'M', + Married: 'S', + Nationality: 'str', + IdType: 'st', + IdNumber: 'string', + IdExpiryDate: '2025-05-26', + EducationLevel: 'stri', + ProfessionCode: 0, + }, + }, +}; diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts new file mode 100644 index 0000000..9505f2f --- /dev/null +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { NeoLeapService } from '../services/neoleap.service'; + +@Controller('neotest') +@ApiTags('NeoTest') +export class NeoTestController { + constructor(private readonly neoleapService: NeoLeapService) {} + + @Get('inquire-application') + async inquireApplication() { + return this.neoleapService.inquireApplication('1234567890'); + } +} diff --git a/src/common/modules/neoleap/dtos/response/index.ts b/src/common/modules/neoleap/dtos/response/index.ts new file mode 100644 index 0000000..6a8d1fe --- /dev/null +++ b/src/common/modules/neoleap/dtos/response/index.ts @@ -0,0 +1 @@ +export * from './inquire-application.response'; diff --git a/src/common/modules/neoleap/dtos/response/inquire-application.response.ts b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts new file mode 100644 index 0000000..96ce358 --- /dev/null +++ b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts @@ -0,0 +1,133 @@ +import { Expose, Transform } from 'class-transformer'; + +export class InquireApplicationResponse { + @Transform(({ obj }) => obj.ApplicationDetails?.ApplicationNumber) + @Expose() + applicationNumber!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.ExternalApplicationNumber) + @Expose() + externalApplicationNumber!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.ApplicationStatus) + @Expose() + applicationStatus!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.Organization) + @Expose() + organization!: number; + + @Transform(({ obj }) => obj.ApplicationDetails?.Product) + @Expose() + product!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.ApplicatonDate) + @Expose() + applicationDate!: string; + @Transform(({ obj }) => obj.ApplicationDetails?.ApplicationSource) + @Expose() + applicationSource!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.SalesSource) + @Expose() + salesSource!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.DeliveryMethod) + @Expose() + deliveryMethod!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.ProgramCode) + @Expose() + ProgramCode!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.Plastic) + @Expose() + plastic!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.Design) + @Expose() + design!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.ProcessStage) + @Expose() + processStage!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.ProcessStageStatus) + @Expose() + processStageStatus!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.EligibilityCheckResult) + @Expose() + eligibilityCheckResult!: string; + @Transform(({ obj }) => obj.ApplicationDetails?.EligibilityCheckDescription) + @Expose() + eligibilityCheckDescription!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.Title) + @Expose() + title!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.FirstName) + @Expose() + firstName!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.SecondName) + @Expose() + secondName!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.ThirdName) + @Expose() + thirdName!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.LastName) + @Expose() + lastName!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.FullName) + @Expose() + fullName!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.EmbossName) + @Expose() + embossName!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.PlaceOfBirth) + @Expose() + placeOfBirth!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.DateOfBirth) + @Expose() + dateOfBirth!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.LocalizedDateOfBirth) + @Expose() + localizedDateOfBirth!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.Age) + @Expose() + age!: number; + + @Transform(({ obj }) => obj.ApplicationDetails?.Gender) + @Expose() + gender!: string; + + @Transform(({ obj }) => obj.ApplicationDetails?.Married) + @Expose() + married!: string; + + @Transform(({ obj }) => obj.ApplicationDetails.Nationality) + @Expose() + nationality!: string; + + @Transform(({ obj }) => obj.ApplicationDetails.IdType) + @Expose() + idType!: string; + + @Transform(({ obj }) => obj.ApplicationDetails.IdNumber) + @Expose() + idNumber!: string; + + @Transform(({ obj }) => obj.ApplicationDetails.IdExpiryDate) + @Expose() + idExpiryDate!: string; +} diff --git a/src/common/modules/neoleap/interfaces/index.ts b/src/common/modules/neoleap/interfaces/index.ts index 97472ef..e6757c9 100644 --- a/src/common/modules/neoleap/interfaces/index.ts +++ b/src/common/modules/neoleap/interfaces/index.ts @@ -1,2 +1,3 @@ export * from './create-application.request.interface'; +export * from './inquire-application.request.interface'; export * from './neoleap-header.request.interface'; diff --git a/src/common/modules/neoleap/interfaces/inquire-application.request.interface.ts b/src/common/modules/neoleap/interfaces/inquire-application.request.interface.ts new file mode 100644 index 0000000..866c61b --- /dev/null +++ b/src/common/modules/neoleap/interfaces/inquire-application.request.interface.ts @@ -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[]; + }; +} diff --git a/src/common/modules/neoleap/neoleap.module.ts b/src/common/modules/neoleap/neoleap.module.ts index c68989f..e3563db 100644 --- a/src/common/modules/neoleap/neoleap.module.ts +++ b/src/common/modules/neoleap/neoleap.module.ts @@ -1,8 +1,11 @@ +import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +import { NeoTestController } from './controllers/neotest.controller'; +import { NeoLeapService } from './services/neoleap.service'; @Module({ - imports: [], - controllers: [], - providers: [], + imports: [HttpModule], + controllers: [NeoTestController], + providers: [NeoLeapService], }) export class NeoLeapModule {} diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index ac10e07..950d231 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -1,11 +1,14 @@ import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { 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 } from '~/customer/enums'; +import { INQUIRE_APPLICATION_MOCK } from '../__mocks__/'; +import { InquireApplicationResponse } from '../dtos/response'; import { ICreateApplicationRequest, INeoleapHeaderRequest } from '../interfaces'; @Injectable() export class NeoLeapService { @@ -55,7 +58,7 @@ export class NeoLeapService { Title: customer.gender === Gender.MALE ? 'Mr' : 'Ms', Gender: customer.gender === Gender.MALE ? 'M' : 'F', LocalizedDateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'), - Nationality: '682', + Nationality: CountriesNumericISO[customer.countryOfResidence], }, ApplicationAddress: { City: customer.city, @@ -75,7 +78,7 @@ export class NeoLeapService { ...this.prepareHeaders('CreateNewApplication'), }; - const response = await this.httpService.axiosRef.post(`${this.baseUrl}/create-application`, payload, { + const response = await this.httpService.axiosRef.post(`${this.baseUrl}/application/CreateNewApplication`, payload, { headers: { 'Content-Type': 'application/json', Authorization: `${this.apiKey}`, @@ -85,6 +88,48 @@ export class NeoLeapService { //@TODO handle response } + async inquireApplication(externalApplicationNumber: string) { + const responseKey = 'InquireApplicationResponseDetails'; + if (this.useMock) { + return plainToInstance(InquireApplicationResponse, INQUIRE_APPLICATION_MOCK[responseKey], { + excludeExtraneousValues: true, + }); + } + + const payload = { + InquireApplicationRequestDetails: { + ApplicationIdentifier: { + InstitutionCode: this.institutionCode, + ExternalApplicationNumber: externalApplicationNumber, + }, + AdditionalData: { + ReturnApplicationType: true, + ReturnApplicationStatus: true, + ReturnCard: true, + ReturnCustomer: true, + }, + }, + ...this.prepareHeaders('InquireApplication'), + }; + + const response = await this.httpService.axiosRef.post(`${this.baseUrl}/application/InquireApplication`, payload, { + headers: { + 'Content-Type': 'application/json', + Authorization: `${this.apiKey}`, + }, + }); + + if (response.data?.[responseKey]) { + return plainToInstance(InquireApplicationResponse, response.data[responseKey], { + excludeExtraneousValues: true, + }); + } else { + throw new Error('Invalid response from NeoLeap API'); + } + + //@TODO handle response + } + private prepareHeaders(serviceName: string): INeoleapHeaderRequest { return { RequestHeader: { From 1ea1f4216990e652c7799f91223faaa40226df56 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Tue, 3 Jun 2025 14:51:36 +0300 Subject: [PATCH 04/24] feat: finish create and inquire application api and handle response and errors --- src/app.module.ts | 2 + src/card/card.module.ts | 8 + src/card/entities/card.entity.ts | 66 ++ src/card/entities/index.ts | 1 + src/card/enums/card-colors.enum.ts | 4 + src/card/enums/card-issuers.enum.ts | 3 + src/card/enums/card-scheme.enum.ts | 4 + src/card/enums/card-status.enum.ts | 6 + src/card/enums/customer-type.enum.ts | 4 + src/card/enums/index.ts | 5 + .../__mocks__/create-application.mock.ts | 750 +++++++++++++++++ src/common/modules/neoleap/__mocks__/index.ts | 1 + .../__mocks__/inquire-application.mock.ts | 785 ++++++++++++++++-- .../neoleap/controllers/neotest.controller.ts | 12 +- .../create-application.response.dto.ts | 12 + .../modules/neoleap/dtos/response/index.ts | 1 + .../response/inquire-application.response.ts | 10 + .../create-application.request.interface.ts | 9 + .../neoleap-header.request.interface.ts | 2 +- src/common/modules/neoleap/neoleap.module.ts | 3 +- .../neoleap/services/neoleap.service.ts | 116 ++- src/customer/entities/customer.entity.ts | 14 + src/customer/services/customer.service.ts | 4 + 23 files changed, 1698 insertions(+), 124 deletions(-) create mode 100644 src/card/card.module.ts create mode 100644 src/card/entities/card.entity.ts create mode 100644 src/card/entities/index.ts create mode 100644 src/card/enums/card-colors.enum.ts create mode 100644 src/card/enums/card-issuers.enum.ts create mode 100644 src/card/enums/card-scheme.enum.ts create mode 100644 src/card/enums/card-status.enum.ts create mode 100644 src/card/enums/customer-type.enum.ts create mode 100644 src/card/enums/index.ts create mode 100644 src/common/modules/neoleap/__mocks__/create-application.mock.ts create mode 100644 src/common/modules/neoleap/dtos/response/create-application.response.dto.ts diff --git a/src/app.module.ts b/src/app.module.ts index 8b9bc17..a2cae02 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -31,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: [], @@ -82,6 +83,7 @@ import { UserModule } from './user/user.module'; CronModule, NeoLeapModule, + CardModule, ], providers: [ // Global Pipes diff --git a/src/card/card.module.ts b/src/card/card.module.ts new file mode 100644 index 0000000..f6f616f --- /dev/null +++ b/src/card/card.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Card } from './entities'; + +@Module({ + imports: [TypeOrmModule.forFeature([Card])], +}) +export class CardModule {} diff --git a/src/card/entities/card.entity.ts b/src/card/entities/card.entity.ts new file mode 100644 index 0000000..bc992da --- /dev/null +++ b/src/card/entities/card.entity.ts @@ -0,0 +1,66 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Customer } from '~/customer/entities'; +import { CardColors, CardIssuers, CardScheme, CardStatus, CustomerType } from '../enums'; + +@Entity() +export class Card { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Index({ unique: true }) + @Column({ name: 'card_reference', nullable: false, type: 'varchar' }) + cardReference!: string; + + @Column({ length: 5, name: 'first_five_digits', nullable: false, type: 'varchar' }) + firstFiveDigits!: string; + + @Column({ length: 4, name: 'last_four_digits', nullable: false, type: 'varchar' }) + lastFourDigits!: string; + + @Column({ type: 'date', nullable: false }) + expiry!: Date; + + @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: 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; + + @ManyToOne(() => Customer, (customer) => customer.childCards) + @JoinColumn({ name: 'parent_id' }) + parentCustomer?: Customer; + + @ManyToOne(() => Customer, (customer) => customer.cards, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'customer_id' }) + customer!: Customer; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone', name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/src/card/entities/index.ts b/src/card/entities/index.ts new file mode 100644 index 0000000..1c77083 --- /dev/null +++ b/src/card/entities/index.ts @@ -0,0 +1 @@ +export * from './card.entity'; diff --git a/src/card/enums/card-colors.enum.ts b/src/card/enums/card-colors.enum.ts new file mode 100644 index 0000000..9fe8e47 --- /dev/null +++ b/src/card/enums/card-colors.enum.ts @@ -0,0 +1,4 @@ +export enum CardColors { + RED = 'RED', + BLUE = 'BLUE', +} diff --git a/src/card/enums/card-issuers.enum.ts b/src/card/enums/card-issuers.enum.ts new file mode 100644 index 0000000..8c31985 --- /dev/null +++ b/src/card/enums/card-issuers.enum.ts @@ -0,0 +1,3 @@ +export enum CardIssuers { + NEOLEAP = 'NEOLEAP', +} diff --git a/src/card/enums/card-scheme.enum.ts b/src/card/enums/card-scheme.enum.ts new file mode 100644 index 0000000..f4c6dcf --- /dev/null +++ b/src/card/enums/card-scheme.enum.ts @@ -0,0 +1,4 @@ +export enum CardScheme { + VISA = 'VISA', + MASTERCARD = 'MASTERCARD', +} diff --git a/src/card/enums/card-status.enum.ts b/src/card/enums/card-status.enum.ts new file mode 100644 index 0000000..e48ad71 --- /dev/null +++ b/src/card/enums/card-status.enum.ts @@ -0,0 +1,6 @@ +export enum CardStatus { + ACTIVE = 'ACTIVE', + CANCELED = 'CANCELED', + BLOCKED = 'BLOCKED', + PENDING = 'PENDING', +} diff --git a/src/card/enums/customer-type.enum.ts b/src/card/enums/customer-type.enum.ts new file mode 100644 index 0000000..4a236e3 --- /dev/null +++ b/src/card/enums/customer-type.enum.ts @@ -0,0 +1,4 @@ +export enum CustomerType { + PARENT = 'PARENT', + CHILD = 'CHILD', +} diff --git a/src/card/enums/index.ts b/src/card/enums/index.ts new file mode 100644 index 0000000..6a9d197 --- /dev/null +++ b/src/card/enums/index.ts @@ -0,0 +1,5 @@ +export * from './card-colors.enum'; +export * from './card-issuers.enum'; +export * from './card-scheme.enum'; +export * from './card-status.enum'; +export * from './customer-type.enum'; diff --git a/src/common/modules/neoleap/__mocks__/create-application.mock.ts b/src/common/modules/neoleap/__mocks__/create-application.mock.ts new file mode 100644 index 0000000..760d2c4 --- /dev/null +++ b/src/common/modules/neoleap/__mocks__/create-application.mock.ts @@ -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, + }, + ], + }, +}; diff --git a/src/common/modules/neoleap/__mocks__/index.ts b/src/common/modules/neoleap/__mocks__/index.ts index 653007b..db8964a 100644 --- a/src/common/modules/neoleap/__mocks__/index.ts +++ b/src/common/modules/neoleap/__mocks__/index.ts @@ -1 +1,2 @@ +export * from './create-application.mock'; export * from './inquire-application.mock'; diff --git a/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts b/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts index 3a74ca7..6f2b7d2 100644 --- a/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts +++ b/src/common/modules/neoleap/__mocks__/inquire-application.mock.ts @@ -1,91 +1,728 @@ export const INQUIRE_APPLICATION_MOCK = { ResponseHeader: { Version: '1.0.0', - MsgUid: 'stringstringstringstringstringstring', - Source: 'string', - ServiceId: 'string', - ReqDateTime: '2025-05-26T10:23:13.812Z', - RspDateTime: '2025-05-26T10:23:13.812Z', - ResponseCode: 'string', - ResponseType: 'Validation Error', - ProcessingTime: 0, - EncryptionKey: 'string', - ResponseDescription: 'string', - LocalizedResponseDescription: { - Locale: 'string', - LocalizedDescription: 'string', - }, - CustomerSpecificResponseDescriptionList: [ - { - Locale: 'string', - ResponseDescription: 'string', - }, - ], - HeaderUserDataList: [ - { - Tag: 'string', - Value: 'string', - }, - ], + 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: 'stri', + InstitutionCode: '1100', ApplicationTypeDetails: { - TypeCode: 'st', - Description: 'string', - Additional: true, - Corporate: true, - UserData: 'string', + TypeCode: '01', + Description: 'Normal Primary', + Additional: false, + Corporate: false, + UserData: null, }, ApplicationDetails: { - ApplicationNumber: 'string', - ExternalApplicationNumber: 'string', - ApplicationStatus: 'st', + cif: null, + ApplicationNumber: '3300000000070', + ExternalApplicationNumber: '10000002', + ApplicationStatus: '04', Organization: 0, - Product: 'stri', - ApplicatonDate: '2025-05-26', - ApplicationSource: 's', - SalesSource: 'string', - DeliveryMethod: 's', - ProgramCode: 'string', - Campaign: 'string', - Plastic: 'string', - Design: 'string', - ProcessStage: 'st', - ProcessStageStatus: 's', - Score: 'string', - ExternalScore: 'string', + 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: 0, + SuggestedLimit: null, AssignedLimit: 0, - AllowedLimitList: [ + 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: [ { - CreditLimit: 0, - EvaluationCode: 'string', + 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', }, ], - EligibilityCheckResult: 'string', - EligibilityCheckDescription: 'string', - Title: 'string', - FirstName: 'string', - SecondName: 'string', - ThirdName: 'string', - LastName: 'string', - FullName: 'string', - EmbossName: 'string', - PlaceOfBirth: 'string', - DateOfBirth: '2025-05-26', - LocalizedDateOfBirth: '2025-05-26', - Age: 20, - Gender: 'M', - Married: 'S', - Nationality: 'str', - IdType: 'st', - IdNumber: 'string', - IdExpiryDate: '2025-05-26', - EducationLevel: 'stri', - ProfessionCode: 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, }, }; diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts index 9505f2f..5475442 100644 --- a/src/common/modules/neoleap/controllers/neotest.controller.ts +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -1,14 +1,22 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Customer } from '~/customer/entities'; +import { CustomerService } from '~/customer/services'; import { NeoLeapService } from '../services/neoleap.service'; @Controller('neotest') -@ApiTags('NeoTest') +@ApiTags('Neoleap Test API , for testing purposes only, will be removed in production') export class NeoTestController { - constructor(private readonly neoleapService: NeoLeapService) {} + constructor(private readonly neoleapService: NeoLeapService, private readonly customerService: CustomerService) {} @Get('inquire-application') async inquireApplication() { return this.neoleapService.inquireApplication('1234567890'); } + + @Get('create-application') + async createApplication() { + const customer = await this.customerService.findAnyCustomer(); + return this.neoleapService.createApplication(customer as Customer); + } } diff --git a/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts new file mode 100644 index 0000000..3e186f8 --- /dev/null +++ b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts @@ -0,0 +1,12 @@ +import { Expose, Transform } from 'class-transformer'; +import { InquireApplicationResponse } from './inquire-application.response'; + +export class CreateApplicationResponse extends InquireApplicationResponse { + @Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier.Id) + @Expose() + cardId!: number; + + @Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier.VPan) + @Expose() + vpan!: string; +} diff --git a/src/common/modules/neoleap/dtos/response/index.ts b/src/common/modules/neoleap/dtos/response/index.ts index 6a8d1fe..50e4920 100644 --- a/src/common/modules/neoleap/dtos/response/index.ts +++ b/src/common/modules/neoleap/dtos/response/index.ts @@ -1 +1,2 @@ +export * from './create-application.response.dto'; export * from './inquire-application.response'; diff --git a/src/common/modules/neoleap/dtos/response/inquire-application.response.ts b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts index 96ce358..b31c69d 100644 --- a/src/common/modules/neoleap/dtos/response/inquire-application.response.ts +++ b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts @@ -21,9 +21,11 @@ export class InquireApplicationResponse { @Expose() product!: string; + // this typo is from neoleap, so we keep it as is @Transform(({ obj }) => obj.ApplicationDetails?.ApplicatonDate) @Expose() applicationDate!: string; + @Transform(({ obj }) => obj.ApplicationDetails?.ApplicationSource) @Expose() applicationSource!: string; @@ -130,4 +132,12 @@ export class InquireApplicationResponse { @Transform(({ obj }) => obj.ApplicationDetails.IdExpiryDate) @Expose() idExpiryDate!: string; + + @Transform(({ obj }) => obj.ApplicationStatusDetails?.Description) + @Expose() + applicationStatusDescription!: string; + + @Transform(({ obj }) => obj.ApplicationStatusDetails?.Canceled) + @Expose() + canceled!: boolean; } diff --git a/src/common/modules/neoleap/interfaces/create-application.request.interface.ts b/src/common/modules/neoleap/interfaces/create-application.request.interface.ts index 9b26632..9b313c6 100644 --- a/src/common/modules/neoleap/interfaces/create-application.request.interface.ts +++ b/src/common/modules/neoleap/interfaces/create-application.request.interface.ts @@ -19,6 +19,15 @@ export interface ICreateApplicationRequest extends INeoleapHeaderRequest { AssignedLimit: number; }; + ApplicationFinancialInformation: { + Currency: { + AlphaCode: 'SAR'; + }; + BillingCycle: 'C1'; + }; + + ApplicationOtherInfo: object; + ApplicationCustomerDetails: { Title: string; FirstName: string; diff --git a/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts b/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts index 5e106f4..81f4592 100644 --- a/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts +++ b/src/common/modules/neoleap/interfaces/neoleap-header.request.interface.ts @@ -2,7 +2,7 @@ export interface INeoleapHeaderRequest { RequestHeader: { Version: string; MsgUid: string; - Source: 'FINTECH'; + Source: 'ZOD'; ServiceId: string; ReqDateTime: Date; }; diff --git a/src/common/modules/neoleap/neoleap.module.ts b/src/common/modules/neoleap/neoleap.module.ts index e3563db..232bef5 100644 --- a/src/common/modules/neoleap/neoleap.module.ts +++ b/src/common/modules/neoleap/neoleap.module.ts @@ -1,10 +1,11 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +import { CustomerModule } from '~/customer/customer.module'; import { NeoTestController } from './controllers/neotest.controller'; import { NeoLeapService } from './services/neoleap.service'; @Module({ - imports: [HttpModule], + imports: [HttpModule, CustomerModule], controllers: [NeoTestController], providers: [NeoLeapService], }) diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index 950d231..0a68b68 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -1,15 +1,15 @@ import { HttpService } from '@nestjs/axios'; -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { plainToInstance } from 'class-transformer'; +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 } from '~/customer/enums'; -import { INQUIRE_APPLICATION_MOCK } from '../__mocks__/'; -import { InquireApplicationResponse } from '../dtos/response'; -import { ICreateApplicationRequest, INeoleapHeaderRequest } from '../interfaces'; +import { CREATE_APPLICATION_MOCK, INQUIRE_APPLICATION_MOCK } from '../__mocks__/'; +import { CreateApplicationResponse, InquireApplicationResponse } from '../dtos/response'; +import { ICreateApplicationRequest, IInquireApplicationRequest, INeoleapHeaderRequest } from '../interfaces'; @Injectable() export class NeoLeapService { private readonly baseUrl: string; @@ -23,9 +23,11 @@ export class NeoLeapService { } async createApplication(customer: Customer) { + const responseKey = 'CreateNewApplicationResponseDetails'; if (this.useMock) { - //@TODO return mock data - return; + return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], { + excludeExtraneousValues: true, + }); } const payload: ICreateApplicationRequest = { @@ -33,8 +35,8 @@ export class NeoLeapService { ApplicationRequestDetails: { InstitutionCode: this.institutionCode, ExternalApplicationNumber: customer.waitingNumber.toString(), - ApplicationType: 'New', - Product: '3001', + ApplicationType: '01', + Product: '1101', ApplicationDate: moment().format('YYYY-MM-DD'), BranchCode: '000', ApplicationSource: 'O', @@ -46,12 +48,19 @@ export class NeoLeapService { AssignedLimit: 0, ProcessControl: 'STND', }, + ApplicationFinancialInformation: { + Currency: { + AlphaCode: 'SAR', + }, + BillingCycle: 'C1', + }, + ApplicationOtherInfo: {}, ApplicationCustomerDetails: { FirstName: customer.firstName, LastName: customer.lastName, - FullName: `${customer.firstName} ${customer.lastName}`, + FullName: customer.fullName, DateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'), - EmbossName: `${customer.firstName} ${customer.lastName}`, // TODO Enter Emboss Name + EmbossName: customer.fullName.toUpperCase(), // TODO Enter Emboss Name IdType: '001', IdNumber: customer.nationalId, IdExpiryDate: moment(customer.nationalIdExpiry).format('YYYY-MM-DD'), @@ -75,17 +84,15 @@ export class NeoLeapService { }, }, }, - ...this.prepareHeaders('CreateNewApplication'), + RequestHeader: this.prepareHeaders('CreateNewApplication'), }; - const response = await this.httpService.axiosRef.post(`${this.baseUrl}/application/CreateNewApplication`, payload, { - headers: { - 'Content-Type': 'application/json', - Authorization: `${this.apiKey}`, - }, - }); - - //@TODO handle response + return this.sendRequestToNeoLeap( + 'application/CreateNewApplication', + payload, + responseKey, + CreateApplicationResponse, + ); } async inquireApplication(externalApplicationNumber: string) { @@ -105,40 +112,57 @@ export class NeoLeapService { AdditionalData: { ReturnApplicationType: true, ReturnApplicationStatus: true, - ReturnCard: true, ReturnCustomer: true, + ReturnAddresses: true, }, }, - ...this.prepareHeaders('InquireApplication'), + RequestHeader: this.prepareHeaders('InquireApplication'), }; - const response = await this.httpService.axiosRef.post(`${this.baseUrl}/application/InquireApplication`, payload, { - headers: { - 'Content-Type': 'application/json', - Authorization: `${this.apiKey}`, - }, - }); + return this.sendRequestToNeoLeap( + 'application/InquireApplication', + payload, + responseKey, + InquireApplicationResponse, + ); + } - if (response.data?.[responseKey]) { - return plainToInstance(InquireApplicationResponse, response.data[responseKey], { + private prepareHeaders(serviceName: string): INeoleapHeaderRequest['RequestHeader'] { + return { + Version: '1.0.0', + MsgUid: uuid(), + Source: 'ZOD', + ServiceId: serviceName, + ReqDateTime: new Date(), + }; + } + + private async sendRequestToNeoLeap( + endpoint: string, + payload: T, + responseKey: string, + responseClass: ClassConstructor, + ): Promise { + try { + const response = await this.httpService.axiosRef.post(`${this.baseUrl}/${endpoint}`, payload, { + headers: { + 'Content-Type': 'application/json', + Authorization: `${this.apiKey}`, + }, + }); + + if (response.data?.ResponseHeader.ResponseCode !== '000' || !response.data[responseKey]) { + throw new BadRequestException( + response.data.ResponseHeader.ResponseDescription || 'Unexpected Error from Service Provider', + ); + } + + return plainToInstance(responseClass, response.data[responseKey], { excludeExtraneousValues: true, }); - } else { - throw new Error('Invalid response from NeoLeap API'); + } catch (error) { + console.error('Error sending request to NeoLeap:', error); + throw new InternalServerErrorException('Error communicating with NeoLeap service'); } - - //@TODO handle response - } - - private prepareHeaders(serviceName: string): INeoleapHeaderRequest { - return { - RequestHeader: { - Version: '1.0.0', - MsgUid: uuid(), - Source: 'FINTECH', - ServiceId: serviceName, - ReqDateTime: new Date(), - }, - }; } } diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index 01230ae..b8bdc65 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -5,10 +5,12 @@ 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'; @@ -125,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}`; + } } diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index 85c4d0a..dff93ff 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -198,4 +198,8 @@ export class CustomerService { customer.civilIdBack.url = civilIdBackUrl; return customer; } + + async findAnyCustomer() { + return this.customerRepository.findOne({ isGuardian: true }); + } } From d1a6d3e71516f58e1700496efc0ccdcbb9fbf1d1 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Wed, 4 Jun 2025 10:04:45 +0300 Subject: [PATCH 05/24] feat: add test controller for integartion --- .gitignore | 2 ++ nest-cli.json | 1 + .../neoleap/controllers/neotest.controller.ts | 2 +- .../modules/neoleap/services/neoleap.service.ts | 14 ++++++++++++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6732258..4cb3dae 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,5 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +zod-certs diff --git a/nest-cli.json b/nest-cli.json index 60e4148..87dfbbf 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -11,6 +11,7 @@ "exclude": "**/*.md" }, { "include": "common/modules/**/templates/**/*", "watchAssets": true }, + { "include": "common/modules/neoleap/zod-certs" }, "i18n", "files" ] diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts index 5475442..b8a3250 100644 --- a/src/common/modules/neoleap/controllers/neotest.controller.ts +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -11,7 +11,7 @@ export class NeoTestController { @Get('inquire-application') async inquireApplication() { - return this.neoleapService.inquireApplication('1234567890'); + return this.neoleapService.inquireApplication('1'); } @Get('create-application') diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index 0a68b68..fd9fbce 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -2,7 +2,10 @@ import { HttpService } from '@nestjs/axios'; import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ClassConstructor, plainToInstance } from 'class-transformer'; +import { readFileSync } from 'fs'; +import { Agent } from 'https'; import moment from 'moment'; +import path from 'path'; import { v4 as uuid } from 'uuid'; import { CountriesNumericISO } from '~/common/constants'; import { Customer } from '~/customer/entities'; @@ -16,10 +19,12 @@ export class NeoLeapService { private readonly apiKey: string; private readonly useMock: boolean; private readonly institutionCode = '1100'; + useLocalCert: boolean; constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { this.baseUrl = this.configService.getOrThrow('NEOLEAP_BASE_URL'); this.apiKey = this.configService.getOrThrow('NEOLEAP_API_KEY'); this.useMock = [true, 'true'].includes(this.configService.get('USE_MOCK', false)); + this.useLocalCert = this.configService.get('USE_LOCAL_CERT', false); } async createApplication(customer: Customer) { @@ -148,7 +153,16 @@ export class NeoLeapService { headers: { 'Content-Type': 'application/json', Authorization: `${this.apiKey}`, + Host: 'apigw-uat.neoleap.com.sa', }, + httpsAgent: new Agent({ + rejectUnauthorized: false, // Disable SSL verification for development purposes + ca: this.useLocalCert ? readFileSync(path.join(__dirname, '../zod-certs/My_CA_Bundle.ca-bundle')) : undefined, + cert: this.useLocalCert + ? readFileSync(path.join(__dirname, '../zod-certs/gw-dev_zodwallet_com.crt')) + : undefined, + key: this.useLocalCert ? readFileSync(path.join(__dirname, '../zod-certs/server.key')) : undefined, + }), }); if (response.data?.ResponseHeader.ResponseCode !== '000' || !response.data[responseKey]) { From d3057beb54384afca9799fe11f37c838c2e9b9fe Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Wed, 2 Jul 2025 18:42:38 +0300 Subject: [PATCH 06/24] feat: add transaction, card , and account entities --- src/card/card.module.ts | 19 ++- src/card/entities/account.entity.ts | 31 ++++ src/card/entities/card.entity.ts | 23 ++- src/card/entities/transaction.entity.ts | 62 +++++++ src/card/enums/index.ts | 1 + src/card/enums/transaction-type.enum.ts | 4 + src/card/repositories/account.repository.ts | 19 +++ src/card/repositories/card.repository.ts | 37 +++++ src/card/repositories/index.ts | 1 + .../repositories/transaction.repository.ts | 34 ++++ src/card/services/account.service.ts | 12 ++ src/card/services/card.service.ts | 37 +++++ src/card/services/index.ts | 1 + src/card/services/transaction.service.ts | 16 ++ .../neoleap-webhooks.controller.ts | 14 ++ .../neoleap/controllers/neotest.controller.ts | 25 ++- .../card-transaction-webhook.request.dto.ts | 153 ++++++++++++++++++ .../modules/neoleap/dtos/requests/index.ts | 1 + .../create-application.response.dto.ts | 32 +++- .../response/inquire-application.response.ts | 36 +++++ src/common/modules/neoleap/neoleap.module.ts | 6 +- .../neoleap/services/neoleap.service.ts | 15 +- src/core/pipes/validation.pipe.ts | 2 +- src/customer/entities/customer.entity.ts | 2 +- .../repositories/customer.repository.ts | 2 +- .../1749633935436-create-card-entity.ts | 40 +++++ .../1751456987627-create-account-entity.ts | 45 ++++++ .../1751466314709-create-transaction-table.ts | 18 +++ src/db/migrations/index.ts | 3 + 29 files changed, 670 insertions(+), 21 deletions(-) create mode 100644 src/card/entities/account.entity.ts create mode 100644 src/card/entities/transaction.entity.ts create mode 100644 src/card/enums/transaction-type.enum.ts create mode 100644 src/card/repositories/account.repository.ts create mode 100644 src/card/repositories/card.repository.ts create mode 100644 src/card/repositories/index.ts create mode 100644 src/card/repositories/transaction.repository.ts create mode 100644 src/card/services/account.service.ts create mode 100644 src/card/services/card.service.ts create mode 100644 src/card/services/index.ts create mode 100644 src/card/services/transaction.service.ts create mode 100644 src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts create mode 100644 src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts create mode 100644 src/common/modules/neoleap/dtos/requests/index.ts create mode 100644 src/db/migrations/1749633935436-create-card-entity.ts create mode 100644 src/db/migrations/1751456987627-create-account-entity.ts create mode 100644 src/db/migrations/1751466314709-create-transaction-table.ts diff --git a/src/card/card.module.ts b/src/card/card.module.ts index f6f616f..92500ad 100644 --- a/src/card/card.module.ts +++ b/src/card/card.module.ts @@ -1,8 +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])], + imports: [TypeOrmModule.forFeature([Card, Account, Transaction])], + providers: [ + CardService, + CardRepository, + TransactionService, + TransactionRepository, + AccountService, + AccountRepository, + ], + exports: [CardService, TransactionService], }) export class CardModule {} diff --git a/src/card/entities/account.entity.ts b/src/card/entities/account.entity.ts new file mode 100644 index 0000000..cdd7847 --- /dev/null +++ b/src/card/entities/account.entity.ts @@ -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; +} diff --git a/src/card/entities/card.entity.ts b/src/card/entities/card.entity.ts index bc992da..4984a61 100644 --- a/src/card/entities/card.entity.ts +++ b/src/card/entities/card.entity.ts @@ -5,13 +5,16 @@ import { Index, JoinColumn, ManyToOne, + OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; import { Customer } from '~/customer/entities'; import { CardColors, CardIssuers, CardScheme, CardStatus, CustomerType } from '../enums'; +import { Account } from './account.entity'; +import { Transaction } from './transaction.entity'; -@Entity() +@Entity('cards') export class Card { @PrimaryGeneratedColumn('uuid') id!: string; @@ -20,14 +23,14 @@ export class Card { @Column({ name: 'card_reference', nullable: false, type: 'varchar' }) cardReference!: string; - @Column({ length: 5, name: 'first_five_digits', nullable: false, type: 'varchar' }) - firstFiveDigits!: 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: 'date', nullable: false }) - expiry!: Date; + @Column({ type: 'varchar', nullable: false }) + expiry!: string; @Column({ type: 'varchar', nullable: false, name: 'customer_type' }) customerType!: CustomerType; @@ -50,6 +53,9 @@ export class Card { @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; @@ -58,6 +64,13 @@ export class Card { @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; diff --git a/src/card/entities/transaction.entity.ts b/src/card/entities/transaction.entity.ts new file mode 100644 index 0000000..6e4d693 --- /dev/null +++ b/src/card/entities/transaction.entity.ts @@ -0,0 +1,62 @@ +import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { TransactionType } from '../enums'; +import { Account } from './account.entity'; +import { Card } from './card.entity'; + +@Entity('transactions') +export class Transaction { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'card_reference', nullable: true, type: 'varchar' }) + cardReference!: string; + + @Column({ name: 'transaction_type', type: 'varchar', default: TransactionType.EXTERNAL }) + transactionType!: TransactionType; + + @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({ 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; +} diff --git a/src/card/enums/index.ts b/src/card/enums/index.ts index 6a9d197..03f577c 100644 --- a/src/card/enums/index.ts +++ b/src/card/enums/index.ts @@ -3,3 +3,4 @@ export * from './card-issuers.enum'; export * from './card-scheme.enum'; export * from './card-status.enum'; export * from './customer-type.enum'; +export * from './transaction-type.enum'; diff --git a/src/card/enums/transaction-type.enum.ts b/src/card/enums/transaction-type.enum.ts new file mode 100644 index 0000000..a65e819 --- /dev/null +++ b/src/card/enums/transaction-type.enum.ts @@ -0,0 +1,4 @@ +export enum TransactionType { + INTERNAL = 'INTERNAL', + EXTERNAL = 'EXTERNAL', +} diff --git a/src/card/repositories/account.repository.ts b/src/card/repositories/account.repository.ts new file mode 100644 index 0000000..5f4a357 --- /dev/null +++ b/src/card/repositories/account.repository.ts @@ -0,0 +1,19 @@ +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) {} + + createAccount(accountId: string): Promise { + return this.accountRepository.save( + this.accountRepository.create({ + accountReference: accountId, + balance: 0, + currency: '682', + }), + ); + } +} diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts new file mode 100644 index 0000000..a83d71c --- /dev/null +++ b/src/card/repositories/card.repository.ts @@ -0,0 +1,37 @@ +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, CustomerType } from '../enums'; + +@Injectable() +export class CardRepository { + constructor(@InjectRepository(Card) private readonly cardRepository: Repository) {} + + createCard(customerId: string, accountId: string, card: CreateApplicationResponse): Promise { + 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, + status: CardStatus.ACTIVE, + scheme: CardScheme.VISA, + issuer: CardIssuers.NEOLEAP, + accountId: accountId, + }), + ); + } + + getCardById(id: string): Promise { + return this.cardRepository.findOne({ where: { id } }); + } + + getCardByReferenceNumber(referenceNumber: string): Promise { + return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] }); + } +} diff --git a/src/card/repositories/index.ts b/src/card/repositories/index.ts new file mode 100644 index 0000000..8458740 --- /dev/null +++ b/src/card/repositories/index.ts @@ -0,0 +1 @@ +export * from './card.repository'; diff --git a/src/card/repositories/transaction.repository.ts b/src/card/repositories/transaction.repository.ts new file mode 100644 index 0000000..fa11dd8 --- /dev/null +++ b/src/card/repositories/transaction.repository.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import moment from 'moment'; +import { Repository } from 'typeorm'; +import { CardTransactionWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; +import { Card } from '../entities'; +import { Transaction } from '../entities/transaction.entity'; +import { TransactionType } from '../enums'; + +@Injectable() +export class TransactionRepository { + constructor(@InjectRepository(Transaction) private transactionRepository: Repository) {} + + createCardTransaction(card: Card, transactionData: CardTransactionWebhookRequest): Promise { + 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, + }), + ); + } +} diff --git a/src/card/services/account.service.ts b/src/card/services/account.service.ts new file mode 100644 index 0000000..a8c4791 --- /dev/null +++ b/src/card/services/account.service.ts @@ -0,0 +1,12 @@ +import { Injectable } 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 { + return this.accountRepository.createAccount(accountId); + } +} diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts new file mode 100644 index 0000000..25573da --- /dev/null +++ b/src/card/services/card.service.ts @@ -0,0 +1,37 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { Transactional } from 'typeorm-transactional'; +import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response'; +import { Card } from '../entities'; +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 { + const account = await this.accountService.createAccount(cardData.accountId); + return this.cardRepository.createCard(customerId, account.id, cardData); + } + + async getCardById(id: string): Promise { + const card = await this.cardRepository.getCardById(id); + + if (!card) { + throw new BadRequestException('CARD.NOT_FOUND'); + } + + return card; + } + + async getCardByReferenceNumber(referenceNumber: string): Promise { + const card = await this.cardRepository.getCardByReferenceNumber(referenceNumber); + + if (!card) { + throw new BadRequestException('CARD.NOT_FOUND'); + } + + return card; + } +} diff --git a/src/card/services/index.ts b/src/card/services/index.ts new file mode 100644 index 0000000..ea35f0f --- /dev/null +++ b/src/card/services/index.ts @@ -0,0 +1 @@ +export * from './card.service'; diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts new file mode 100644 index 0000000..533bd16 --- /dev/null +++ b/src/card/services/transaction.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { CardTransactionWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; +import { TransactionRepository } from '../repositories/transaction.repository'; +import { CardService } from './card.service'; + +@Injectable() +export class TransactionService { + constructor( + private readonly transactionRepository: TransactionRepository, + private readonly cardService: CardService, + ) {} + async createCardTransaction(body: CardTransactionWebhookRequest) { + const card = await this.cardService.getCardByReferenceNumber(body.cardId); + return this.transactionRepository.createCardTransaction(card, body); + } +} diff --git a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts new file mode 100644 index 0000000..0089a66 --- /dev/null +++ b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts @@ -0,0 +1,14 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { TransactionService } from '~/card/services/transaction.service'; +import { CardTransactionWebhookRequest } from '../dtos/requests'; + +@Controller('neoleap-webhooks') +@ApiTags('Neoleap Webhooks') +export class NeoLeapWebhooksController { + constructor(private readonly transactionService: TransactionService) {} + @Post('account-transaction') + async handleAccountTransactionWebhook(@Body() body: CardTransactionWebhookRequest) { + await this.transactionService.createCardTransaction(body); + } +} diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts index b8a3250..5d8ff8a 100644 --- a/src/common/modules/neoleap/controllers/neotest.controller.ts +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -1,22 +1,39 @@ import { Controller, Get } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { ApiTags } from '@nestjs/swagger'; +import { CardService } from '~/card/services'; +import { ApiDataResponse } from '~/core/decorators'; +import { ResponseFactory } from '~/core/utils'; import { Customer } from '~/customer/entities'; import { CustomerService } from '~/customer/services'; +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') export class NeoTestController { - constructor(private readonly neoleapService: NeoLeapService, private readonly customerService: CustomerService) {} + constructor( + private readonly neoleapService: NeoLeapService, + private readonly customerService: CustomerService, + private readonly cardService: CardService, + private readonly configService: ConfigService, + ) {} @Get('inquire-application') + @ApiDataResponse(InquireApplicationResponse) async inquireApplication() { - return this.neoleapService.inquireApplication('1'); + const data = await this.neoleapService.inquireApplication('15'); + return ResponseFactory.data(data); } @Get('create-application') + @ApiDataResponse(CreateApplicationResponse) async createApplication() { - const customer = await this.customerService.findAnyCustomer(); - return this.neoleapService.createApplication(customer as Customer); + const customer = await this.customerService.findCustomerById( + this.configService.get('MOCK_CUSTOMER_ID', '0778c431-f604-4b91-af53-49c33849b5ff'), + ); + const data = await this.neoleapService.createApplication(customer as Customer); + await this.cardService.createCard(customer.id, data); + return ResponseFactory.data(data); } } diff --git a/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts new file mode 100644 index 0000000..8a59b19 --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts @@ -0,0 +1,153 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IsNumber, IsString, 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() + @ApiProperty({ example: '100.5' }) + transactionAmount!: number; + + @IsString() + @ApiProperty({ example: '682' }) + transactionCurrency!: string; + + @Type(() => Number) + @IsNumber() + @ApiProperty({ example: '100.5' }) + billingAmount!: number; + + @IsString() + @ApiProperty({ example: '682' }) + billingCurrency!: string; + + @Type(() => Number) + @IsNumber() + @ApiProperty({ example: '100.5' }) + settlementAmount!: number; + + @IsString() + @ApiProperty({ example: '682' }) + settlementCurrency!: string; + + @Expose() + @Type(() => Number) + @IsNumber() + @ApiProperty({ example: '20' }) + fees!: number; + + @Expose() + @Type(() => Number) + @IsNumber() + @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; +} diff --git a/src/common/modules/neoleap/dtos/requests/index.ts b/src/common/modules/neoleap/dtos/requests/index.ts new file mode 100644 index 0000000..a62ed5c --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/index.ts @@ -0,0 +1 @@ +export * from './card-transaction-webhook.request.dto'; diff --git a/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts index 3e186f8..f135b4f 100644 --- a/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts +++ b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts @@ -1,12 +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) + @Transform(({ obj }) => obj.CardDetailsList?.[0]?.ResponseCardIdentifier.Id.toString()) @Expose() - cardId!: number; + @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; } diff --git a/src/common/modules/neoleap/dtos/response/inquire-application.response.ts b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts index b31c69d..aeb7165 100644 --- a/src/common/modules/neoleap/dtos/response/inquire-application.response.ts +++ b/src/common/modules/neoleap/dtos/response/inquire-application.response.ts @@ -1,143 +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; } diff --git a/src/common/modules/neoleap/neoleap.module.ts b/src/common/modules/neoleap/neoleap.module.ts index 232bef5..78c0224 100644 --- a/src/common/modules/neoleap/neoleap.module.ts +++ b/src/common/modules/neoleap/neoleap.module.ts @@ -1,12 +1,14 @@ 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 { NeoLeapService } from './services/neoleap.service'; @Module({ - imports: [HttpModule, CustomerModule], - controllers: [NeoTestController], + imports: [HttpModule, CustomerModule, CardModule], + controllers: [NeoTestController, NeoLeapWebhooksController], providers: [NeoLeapService], }) export class NeoLeapModule {} diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index fd9fbce..3c7802b 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -29,6 +29,9 @@ export class NeoLeapService { async createApplication(customer: Customer) { const responseKey = 'CreateNewApplicationResponseDetails'; + if (customer.cards.length > 0) { + throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD'); + } if (this.useMock) { return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], { excludeExtraneousValues: true, @@ -39,7 +42,7 @@ export class NeoLeapService { CreateNewApplicationRequestDetails: { ApplicationRequestDetails: { InstitutionCode: this.institutionCode, - ExternalApplicationNumber: customer.waitingNumber.toString(), + ExternalApplicationNumber: (customer.waitingNumber * 64).toString(), ApplicationType: '01', Product: '1101', ApplicationDate: moment().format('YYYY-MM-DD'), @@ -66,7 +69,7 @@ export class NeoLeapService { FullName: customer.fullName, DateOfBirth: moment(customer.dateOfBirth).format('YYYY-MM-DD'), EmbossName: customer.fullName.toUpperCase(), // TODO Enter Emboss Name - IdType: '001', + IdType: '01', IdNumber: customer.nationalId, IdExpiryDate: moment(customer.nationalIdExpiry).format('YYYY-MM-DD'), Title: customer.gender === Gender.MALE ? 'Mr' : 'Ms', @@ -76,7 +79,7 @@ export class NeoLeapService { }, ApplicationAddress: { City: customer.city, - Country: CountriesNumericISO[customer.countryOfResidence], + Country: CountriesNumericISO[customer.country], Region: customer.region, AddressLine1: `${customer.street} ${customer.building}`, AddressLine2: customer.neighborhood, @@ -174,7 +177,11 @@ export class NeoLeapService { return plainToInstance(responseClass, response.data[responseKey], { excludeExtraneousValues: true, }); - } catch (error) { + } 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'); } diff --git a/src/core/pipes/validation.pipe.ts b/src/core/pipes/validation.pipe.ts index 505fb03..5153b10 100644 --- a/src/core/pipes/validation.pipe.ts +++ b/src/core/pipes/validation.pipe.ts @@ -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, diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index b8bdc65..96786c0 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -79,7 +79,7 @@ export class Customer extends BaseEntity { userId!: string; @Column('varchar', { name: 'country', length: 255, nullable: true }) - country!: string; + country!: CountryIso; @Column('varchar', { name: 'region', length: 255, nullable: true }) region!: string; diff --git a/src/customer/repositories/customer.repository.ts b/src/customer/repositories/customer.repository.ts index a17b383..85565a6 100644 --- a/src/customer/repositories/customer.repository.ts +++ b/src/customer/repositories/customer.repository.ts @@ -15,7 +15,7 @@ export class CustomerRepository { findOne(where: FindOptionsWhere) { return this.customerRepository.findOne({ where, - relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack'], + relations: ['profilePicture', 'user', 'civilIdFront', 'civilIdBack', 'cards'], }); } diff --git a/src/db/migrations/1749633935436-create-card-entity.ts b/src/db/migrations/1749633935436-create-card-entity.ts new file mode 100644 index 0000000..4ae45ab --- /dev/null +++ b/src/db/migrations/1749633935436-create-card-entity.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateCardEntity1749633935436 implements MigrationInterface { + name = 'CreateCardEntity1749633935436'; + + public async up(queryRunner: QueryRunner): Promise { + 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 "card" ("card_reference") `); + await queryRunner.query( + `ALTER TABLE "card" ADD CONSTRAINT "FK_d46837f6ab27271d8125517d0b6" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "card" ADD CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "card" DROP CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e"`); + await queryRunner.query(`ALTER TABLE "card" DROP CONSTRAINT "FK_d46837f6ab27271d8125517d0b6"`); + await queryRunner.query(`DROP INDEX "public"."IDX_6fb82820c0b1b7ec32a8dbf911"`); + await queryRunner.query(`DROP TABLE "card"`); + } +} diff --git a/src/db/migrations/1751456987627-create-account-entity.ts b/src/db/migrations/1751456987627-create-account-entity.ts new file mode 100644 index 0000000..40e7a1b --- /dev/null +++ b/src/db/migrations/1751456987627-create-account-entity.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAccountEntity1751456987627 implements MigrationInterface { + name = 'CreateAccountEntity1751456987627'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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`, + ); + } +} diff --git a/src/db/migrations/1751466314709-create-transaction-table.ts b/src/db/migrations/1751466314709-create-transaction-table.ts new file mode 100644 index 0000000..4c5e773 --- /dev/null +++ b/src/db/migrations/1751466314709-create-transaction-table.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateTransactionTable1751466314709 implements MigrationInterface { + name = 'CreateTransactionTable1751466314709' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } + +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 3ad20d0..9e5e580 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -25,3 +25,6 @@ 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'; From 492e538eb86eba54c57cf5addfb77325c0094eb7 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 6 Jul 2025 16:44:23 +0300 Subject: [PATCH 07/24] feat: send request via gateway --- .../neoleap-webhooks.controller.ts | 7 ++-- .../neoleap/controllers/neotest.controller.ts | 2 +- .../neoleap/services/neoleap.service.ts | 39 +++++++------------ 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts index 0089a66..ae0aadb 100644 --- a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts +++ b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts @@ -7,8 +7,9 @@ import { CardTransactionWebhookRequest } from '../dtos/requests'; @ApiTags('Neoleap Webhooks') export class NeoLeapWebhooksController { constructor(private readonly transactionService: TransactionService) {} - @Post('account-transaction') - async handleAccountTransactionWebhook(@Body() body: CardTransactionWebhookRequest) { - await this.transactionService.createCardTransaction(body); + + @Post('card-transaction') + async handleCardTransactionWebhook(@Body() body: CardTransactionWebhookRequest) { + return this.transactionService.createCardTransaction(body); } } diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts index 5d8ff8a..f7ba156 100644 --- a/src/common/modules/neoleap/controllers/neotest.controller.ts +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -30,7 +30,7 @@ export class NeoTestController { @ApiDataResponse(CreateApplicationResponse) async createApplication() { const customer = await this.customerService.findCustomerById( - this.configService.get('MOCK_CUSTOMER_ID', '0778c431-f604-4b91-af53-49c33849b5ff'), + this.configService.get('MOCK_CUSTOMER_ID', 'ff462af1-1e0c-4216-8865-738e5b525ac1'), ); const data = await this.neoleapService.createApplication(customer as Customer); await this.cardService.createCard(customer.id, data); diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index 3c7802b..05ff545 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -2,10 +2,7 @@ import { HttpService } from '@nestjs/axios'; import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ClassConstructor, plainToInstance } from 'class-transformer'; -import { readFileSync } from 'fs'; -import { Agent } from 'https'; import moment from 'moment'; -import path from 'path'; import { v4 as uuid } from 'uuid'; import { CountriesNumericISO } from '~/common/constants'; import { Customer } from '~/customer/entities'; @@ -17,22 +14,22 @@ import { ICreateApplicationRequest, IInquireApplicationRequest, INeoleapHeaderRe export class NeoLeapService { private readonly baseUrl: string; private readonly apiKey: string; - private readonly useMock: boolean; + private readonly useGateway: boolean; private readonly institutionCode = '1100'; useLocalCert: boolean; constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { - this.baseUrl = this.configService.getOrThrow('NEOLEAP_BASE_URL'); - this.apiKey = this.configService.getOrThrow('NEOLEAP_API_KEY'); - this.useMock = [true, 'true'].includes(this.configService.get('USE_MOCK', false)); + this.baseUrl = this.configService.getOrThrow('GATEWAY_URL'); + this.apiKey = this.configService.getOrThrow('GATEWAY_API_KEY'); + this.useGateway = [true, 'true'].includes(this.configService.get('USE_GATEWAY', false)); this.useLocalCert = this.configService.get('USE_LOCAL_CERT', false); } async createApplication(customer: Customer) { const responseKey = 'CreateNewApplicationResponseDetails'; - if (customer.cards.length > 0) { - throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD'); - } - if (this.useMock) { + // if (customer.cards.length > 0) { + // throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD'); + // } + if (!this.useGateway) { return plainToInstance(CreateApplicationResponse, CREATE_APPLICATION_MOCK[responseKey], { excludeExtraneousValues: true, }); @@ -42,7 +39,7 @@ export class NeoLeapService { CreateNewApplicationRequestDetails: { ApplicationRequestDetails: { InstitutionCode: this.institutionCode, - ExternalApplicationNumber: (customer.waitingNumber * 64).toString(), + ExternalApplicationNumber: customer.waitingNumber.toString(), ApplicationType: '01', Product: '1101', ApplicationDate: moment().format('YYYY-MM-DD'), @@ -105,7 +102,7 @@ export class NeoLeapService { async inquireApplication(externalApplicationNumber: string) { const responseKey = 'InquireApplicationResponseDetails'; - if (this.useMock) { + if (!this.useGateway) { return plainToInstance(InquireApplicationResponse, INQUIRE_APPLICATION_MOCK[responseKey], { excludeExtraneousValues: true, }); @@ -152,29 +149,21 @@ export class NeoLeapService { responseClass: ClassConstructor, ): Promise { try { - const response = await this.httpService.axiosRef.post(`${this.baseUrl}/${endpoint}`, payload, { + const { data } = await this.httpService.axiosRef.post(`${this.baseUrl}/${endpoint}`, payload, { headers: { 'Content-Type': 'application/json', Authorization: `${this.apiKey}`, Host: 'apigw-uat.neoleap.com.sa', }, - httpsAgent: new Agent({ - rejectUnauthorized: false, // Disable SSL verification for development purposes - ca: this.useLocalCert ? readFileSync(path.join(__dirname, '../zod-certs/My_CA_Bundle.ca-bundle')) : undefined, - cert: this.useLocalCert - ? readFileSync(path.join(__dirname, '../zod-certs/gw-dev_zodwallet_com.crt')) - : undefined, - key: this.useLocalCert ? readFileSync(path.join(__dirname, '../zod-certs/server.key')) : undefined, - }), }); - if (response.data?.ResponseHeader.ResponseCode !== '000' || !response.data[responseKey]) { + if (data.data?.ResponseHeader?.ResponseCode !== '000' || !data.data[responseKey]) { throw new BadRequestException( - response.data.ResponseHeader.ResponseDescription || 'Unexpected Error from Service Provider', + data.data.ResponseHeader.ResponseDescription || 'Unexpected Error from Service Provider', ); } - return plainToInstance(responseClass, response.data[responseKey], { + return plainToInstance(responseClass, data.data[responseKey], { excludeExtraneousValues: true, }); } catch (error: any) { From 2770cf8774336aa86f103fba6a2722b5afffa5a1 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 7 Jul 2025 12:06:01 +0300 Subject: [PATCH 08/24] fix:fix card migration --- .../migrations/1749633935436-create-card-entity.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/db/migrations/1749633935436-create-card-entity.ts b/src/db/migrations/1749633935436-create-card-entity.ts index 4ae45ab..5a2ed55 100644 --- a/src/db/migrations/1749633935436-create-card-entity.ts +++ b/src/db/migrations/1749633935436-create-card-entity.ts @@ -22,19 +22,19 @@ export class CreateCardEntity1749633935436 implements MigrationInterface { "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 "card" ("card_reference") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_6fb82820c0b1b7ec32a8dbf911" ON "cards" ("card_reference") `); await queryRunner.query( - `ALTER TABLE "card" ADD CONSTRAINT "FK_d46837f6ab27271d8125517d0b6" FOREIGN KEY ("parent_id") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + `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 "card" ADD CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + `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 { - await queryRunner.query(`ALTER TABLE "card" DROP CONSTRAINT "FK_f5aa0baf4ff1b397b3f946a443e"`); - await queryRunner.query(`ALTER TABLE "card" DROP CONSTRAINT "FK_d46837f6ab27271d8125517d0b6"`); + 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 "card"`); + await queryRunner.query(`DROP TABLE "cards"`); } } From 3b3f8c010475dd3b6f7df3d9753e5768550aa45d Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 7 Jul 2025 16:34:45 +0300 Subject: [PATCH 09/24] fix: remove host from request --- src/common/modules/neoleap/services/neoleap.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index 05ff545..8e6403b 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -153,7 +153,6 @@ export class NeoLeapService { headers: { 'Content-Type': 'application/json', Authorization: `${this.apiKey}`, - Host: 'apigw-uat.neoleap.com.sa', }, }); From 038b8ef6e3657e56d006624ab050d4d193cb360c Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Wed, 9 Jul 2025 13:31:08 +0300 Subject: [PATCH 10/24] feat: finish working on account transaction webhook --- package-lock.json | 7 ++ package.json | 1 + src/card/entities/transaction.entity.ts | 13 +++- src/card/enums/index.ts | 1 + src/card/enums/transaction-scope.enum.ts | 4 ++ src/card/repositories/account.repository.ts | 15 ++++ .../repositories/transaction.repository.ts | 35 +++++++++- src/card/services/account.service.ts | 28 +++++++- src/card/services/transaction.service.ts | 56 ++++++++++++++- .../neoleap-webhooks.controller.ts | 7 +- ...account-transaction-webhook.request.dto.ts | 68 +++++++++++++++++++ .../card-transaction-webhook.request.dto.ts | 7 +- .../modules/neoleap/dtos/requests/index.ts | 1 + .../1752056898465-edit-transaction-table.ts | 16 +++++ src/db/migrations/index.ts | 1 + 15 files changed, 249 insertions(+), 11 deletions(-) create mode 100644 src/card/enums/transaction-scope.enum.ts create mode 100644 src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts create mode 100644 src/db/migrations/1752056898465-edit-transaction-table.ts diff --git a/package-lock.json b/package-lock.json index 2a54f0c..3cd82fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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, diff --git a/package.json b/package.json index a991cb2..f486a74 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/card/entities/transaction.entity.ts b/src/card/entities/transaction.entity.ts index 6e4d693..029ef29 100644 --- a/src/card/entities/transaction.entity.ts +++ b/src/card/entities/transaction.entity.ts @@ -1,5 +1,5 @@ import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; -import { TransactionType } from '../enums'; +import { TransactionScope, TransactionType } from '../enums'; import { Account } from './account.entity'; import { Card } from './card.entity'; @@ -8,12 +8,15 @@ export class Transaction { @PrimaryGeneratedColumn('uuid') id!: string; - @Column({ name: 'card_reference', nullable: true, type: 'varchar' }) - cardReference!: 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; @@ -44,6 +47,9 @@ export class Transaction { @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; @@ -57,6 +63,7 @@ export class Transaction { @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; } diff --git a/src/card/enums/index.ts b/src/card/enums/index.ts index 03f577c..cac8b22 100644 --- a/src/card/enums/index.ts +++ b/src/card/enums/index.ts @@ -3,4 +3,5 @@ export * from './card-issuers.enum'; export * from './card-scheme.enum'; export * from './card-status.enum'; export * from './customer-type.enum'; +export * from './transaction-scope.enum'; export * from './transaction-type.enum'; diff --git a/src/card/enums/transaction-scope.enum.ts b/src/card/enums/transaction-scope.enum.ts new file mode 100644 index 0000000..d1afd17 --- /dev/null +++ b/src/card/enums/transaction-scope.enum.ts @@ -0,0 +1,4 @@ +export enum TransactionScope { + CARD = 'CARD', + ACCOUNT = 'ACCOUNT', +} diff --git a/src/card/repositories/account.repository.ts b/src/card/repositories/account.repository.ts index 5f4a357..da15be6 100644 --- a/src/card/repositories/account.repository.ts +++ b/src/card/repositories/account.repository.ts @@ -16,4 +16,19 @@ export class AccountRepository { }), ); } + + getAccountByReferenceNumber(accountReference: string): Promise { + 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); + } } diff --git a/src/card/repositories/transaction.repository.ts b/src/card/repositories/transaction.repository.ts index fa11dd8..39d8665 100644 --- a/src/card/repositories/transaction.repository.ts +++ b/src/card/repositories/transaction.repository.ts @@ -2,10 +2,14 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import moment from 'moment'; import { Repository } from 'typeorm'; -import { CardTransactionWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; +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 { TransactionType } from '../enums'; +import { TransactionScope, TransactionType } from '../enums'; @Injectable() export class TransactionRepository { @@ -28,7 +32,34 @@ export class TransactionRepository { accountId: card.account!.id, transactionType: TransactionType.EXTERNAL, accountReference: card.account!.accountReference, + transactionScope: TransactionScope.CARD, + vatOnFees: transactionData.vatOnFees, }), ); } + + createAccountTransaction(account: Account, transactionData: AccountTransactionWebhookRequest): Promise { + 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 { + return this.transactionRepository.findOne({ + where: { transactionId, accountReference }, + }); + } } diff --git a/src/card/services/account.service.ts b/src/card/services/account.service.ts index a8c4791..cce8e68 100644 --- a/src/card/services/account.service.ts +++ b/src/card/services/account.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { Account } from '../entities/account.entity'; import { AccountRepository } from '../repositories/account.repository'; @@ -9,4 +9,30 @@ export class AccountService { createAccount(accountId: string): Promise { return this.accountRepository.createAccount(accountId); } + + async getAccountByReferenceNumber(accountReference: string): Promise { + 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); + } } diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index 533bd16..6c98b9c 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -1,6 +1,13 @@ -import { Injectable } from '@nestjs/common'; -import { CardTransactionWebhookRequest } from '~/common/modules/neoleap/dtos/requests'; +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() @@ -8,9 +15,52 @@ 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); - return this.transactionRepository.createCardTransaction(card, body); + 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.settlementAmount) + .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 { + const existingTransaction = await this.transactionRepository.findTransactionByReference( + transactionId, + accountReference, + ); + + return existingTransaction; } } diff --git a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts index ae0aadb..05d3bf9 100644 --- a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts +++ b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TransactionService } from '~/card/services/transaction.service'; -import { CardTransactionWebhookRequest } from '../dtos/requests'; +import { AccountTransactionWebhookRequest, CardTransactionWebhookRequest } from '../dtos/requests'; @Controller('neoleap-webhooks') @ApiTags('Neoleap Webhooks') @@ -12,4 +12,9 @@ export class NeoLeapWebhooksController { async handleCardTransactionWebhook(@Body() body: CardTransactionWebhookRequest) { return this.transactionService.createCardTransaction(body); } + + @Post('account-transaction') + async handleAccountTransactionWebhook(@Body() body: AccountTransactionWebhookRequest) { + return this.transactionService.createAccountTransaction(body); + } } diff --git a/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts new file mode 100644 index 0000000..06e1437 --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/account-transaction-webhook.request.dto.ts @@ -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; +} diff --git a/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts index 8a59b19..db8afe5 100644 --- a/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts +++ b/src/common/modules/neoleap/dtos/requests/card-transaction-webhook.request.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Expose, Type } from 'class-transformer'; -import { IsNumber, IsString, ValidateNested } from 'class-validator'; +import { IsNumber, IsString, Min, ValidateNested } from 'class-validator'; export class CardAcceptorLocationDto { @Expose() @@ -99,6 +99,7 @@ export class CardTransactionWebhookRequest { @Type(() => Number) @IsNumber() + @Min(0, { message: 'amount must be zero or a positive number' }) @ApiProperty({ example: '100.5' }) transactionAmount!: number; @@ -108,6 +109,7 @@ export class CardTransactionWebhookRequest { @Type(() => Number) @IsNumber() + @Min(0, { message: 'amount must be zero or a positive number' }) @ApiProperty({ example: '100.5' }) billingAmount!: number; @@ -117,6 +119,7 @@ export class CardTransactionWebhookRequest { @Type(() => Number) @IsNumber() + @Min(0, { message: 'amount must be zero or a positive number' }) @ApiProperty({ example: '100.5' }) settlementAmount!: number; @@ -127,12 +130,14 @@ export class CardTransactionWebhookRequest { @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; diff --git a/src/common/modules/neoleap/dtos/requests/index.ts b/src/common/modules/neoleap/dtos/requests/index.ts index a62ed5c..bf49fc9 100644 --- a/src/common/modules/neoleap/dtos/requests/index.ts +++ b/src/common/modules/neoleap/dtos/requests/index.ts @@ -1 +1,2 @@ +export * from './account-transaction-webhook.request.dto'; export * from './card-transaction-webhook.request.dto'; diff --git a/src/db/migrations/1752056898465-edit-transaction-table.ts b/src/db/migrations/1752056898465-edit-transaction-table.ts new file mode 100644 index 0000000..d4bab41 --- /dev/null +++ b/src/db/migrations/1752056898465-edit-transaction-table.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class EditTransactionTable1752056898465 implements MigrationInterface { + name = 'EditTransactionTable1752056898465' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "vat_on_fees"`); + await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "transaction_scope"`); + } + +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 9e5e580..5efe087 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -28,3 +28,4 @@ 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'; From 5a780eeb1760020afa89472a52417ea2df07bd99 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 14 Jul 2025 11:57:51 +0300 Subject: [PATCH 11/24] feat/working on update card control --- src/card/repositories/card.repository.ts | 6 ++ src/card/services/card.service.ts | 8 ++ src/card/services/transaction.service.ts | 6 +- .../neoleap/controllers/neotest.controller.ts | 47 ++++++++--- .../modules/neoleap/dtos/requests/index.ts | 1 + .../update-card-controls.request.dto.ts | 15 ++++ .../modules/neoleap/dtos/response/index.ts | 1 + .../update-card-controls.response.dto.ts | 1 + .../modules/neoleap/interfaces/index.ts | 1 + .../update-card-control.request.interface.ts | 77 +++++++++++++++++++ .../neoleap/services/neoleap.service.ts | 54 +++++++++++-- .../dtos/response/customer.response.dto.ts | 25 +++++- src/customer/services/customer.service.ts | 43 ++++++++++- src/i18n/ar/app.json | 3 +- src/i18n/en/app.json | 3 +- 15 files changed, 263 insertions(+), 28 deletions(-) create mode 100644 src/common/modules/neoleap/dtos/requests/update-card-controls.request.dto.ts create mode 100644 src/common/modules/neoleap/dtos/response/update-card-controls.response.dto.ts create mode 100644 src/common/modules/neoleap/interfaces/update-card-control.request.interface.ts diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts index a83d71c..ebab1f7 100644 --- a/src/card/repositories/card.repository.ts +++ b/src/card/repositories/card.repository.ts @@ -34,4 +34,10 @@ export class CardRepository { getCardByReferenceNumber(referenceNumber: string): Promise { return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] }); } + + getActiveCardForCustomer(customerId: string): Promise { + return this.cardRepository.findOne({ + where: { customerId, status: CardStatus.ACTIVE }, + }); + } } diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index 25573da..bc71cfd 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -34,4 +34,12 @@ export class CardService { return card; } + + async getActiveCardForCustomer(customerId: string): Promise { + const card = await this.cardRepository.getActiveCardForCustomer(customerId); + if (!card) { + throw new BadRequestException('CARD.NOT_FOUND'); + } + return card; + } } diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index 6c98b9c..d512299 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -28,11 +28,7 @@ export class TransactionService { } const transaction = await this.transactionRepository.createCardTransaction(card, body); - const total = new Decimal(body.transactionAmount) - .plus(body.billingAmount) - .plus(body.settlementAmount) - .plus(body.fees) - .plus(body.vatOnFees); + const total = new Decimal(body.transactionAmount).plus(body.billingAmount).plus(body.fees).plus(body.vatOnFees); await this.accountService.decreaseAccountBalance(card.account.accountReference, total.toNumber()); diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts index f7ba156..47ed777 100644 --- a/src/common/modules/neoleap/controllers/neotest.controller.ts +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -1,16 +1,22 @@ -import { Controller, Get } from '@nestjs/common'; +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { ApiTags } from '@nestjs/swagger'; +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 { Customer } from '~/customer/entities'; +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, @@ -19,21 +25,38 @@ export class NeoTestController { private readonly configService: ConfigService, ) {} - @Get('inquire-application') + @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() { - const data = await this.neoleapService.inquireApplication('15'); + async inquireApplication(@AuthenticatedUser() user: IJwtPayload) { + const customer = await this.customerService.findCustomerById(user.sub); + const data = await this.neoleapService.inquireApplication(customer.waitingNumber.toString()); return ResponseFactory.data(data); } - @Get('create-application') + @Post('create-application') @ApiDataResponse(CreateApplicationResponse) - async createApplication() { - const customer = await this.customerService.findCustomerById( - this.configService.get('MOCK_CUSTOMER_ID', 'ff462af1-1e0c-4216-8865-738e5b525ac1'), - ); - const data = await this.neoleapService.createApplication(customer as Customer); + 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' }); + } } diff --git a/src/common/modules/neoleap/dtos/requests/index.ts b/src/common/modules/neoleap/dtos/requests/index.ts index bf49fc9..d4cad6f 100644 --- a/src/common/modules/neoleap/dtos/requests/index.ts +++ b/src/common/modules/neoleap/dtos/requests/index.ts @@ -1,2 +1,3 @@ export * from './account-transaction-webhook.request.dto'; export * from './card-transaction-webhook.request.dto'; +export * from './update-card-controls.request.dto'; diff --git a/src/common/modules/neoleap/dtos/requests/update-card-controls.request.dto.ts b/src/common/modules/neoleap/dtos/requests/update-card-controls.request.dto.ts new file mode 100644 index 0000000..280c05e --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/update-card-controls.request.dto.ts @@ -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; +} diff --git a/src/common/modules/neoleap/dtos/response/index.ts b/src/common/modules/neoleap/dtos/response/index.ts index 50e4920..457e4bc 100644 --- a/src/common/modules/neoleap/dtos/response/index.ts +++ b/src/common/modules/neoleap/dtos/response/index.ts @@ -1,2 +1,3 @@ export * from './create-application.response.dto'; export * from './inquire-application.response'; +export * from './update-card-controls.response.dto'; diff --git a/src/common/modules/neoleap/dtos/response/update-card-controls.response.dto.ts b/src/common/modules/neoleap/dtos/response/update-card-controls.response.dto.ts new file mode 100644 index 0000000..f39eaa3 --- /dev/null +++ b/src/common/modules/neoleap/dtos/response/update-card-controls.response.dto.ts @@ -0,0 +1 @@ +export class UpdateCardControlsResponseDto {} diff --git a/src/common/modules/neoleap/interfaces/index.ts b/src/common/modules/neoleap/interfaces/index.ts index e6757c9..911935c 100644 --- a/src/common/modules/neoleap/interfaces/index.ts +++ b/src/common/modules/neoleap/interfaces/index.ts @@ -1,3 +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'; diff --git a/src/common/modules/neoleap/interfaces/update-card-control.request.interface.ts b/src/common/modules/neoleap/interfaces/update-card-control.request.interface.ts new file mode 100644 index 0000000..3764887 --- /dev/null +++ b/src/common/modules/neoleap/interfaces/update-card-control.request.interface.ts @@ -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; + }; + }; +} diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index 8e6403b..6f31ac4 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -6,10 +6,15 @@ import moment from 'moment'; import { v4 as uuid } from 'uuid'; import { CountriesNumericISO } from '~/common/constants'; import { Customer } from '~/customer/entities'; -import { Gender } from '~/customer/enums'; +import { Gender, KycStatus } from '~/customer/enums'; import { CREATE_APPLICATION_MOCK, INQUIRE_APPLICATION_MOCK } from '../__mocks__/'; -import { CreateApplicationResponse, InquireApplicationResponse } from '../dtos/response'; -import { ICreateApplicationRequest, IInquireApplicationRequest, INeoleapHeaderRequest } from '../interfaces'; +import { CreateApplicationResponse, InquireApplicationResponse, UpdateCardControlsResponseDto } from '../dtos/response'; +import { + ICreateApplicationRequest, + IInquireApplicationRequest, + INeoleapHeaderRequest, + IUpdateCardControlRequest, +} from '../interfaces'; @Injectable() export class NeoLeapService { private readonly baseUrl: string; @@ -26,9 +31,15 @@ export class NeoLeapService { async createApplication(customer: Customer) { const responseKey = 'CreateNewApplicationResponseDetails'; - // if (customer.cards.length > 0) { - // throw new BadRequestException('CUSTOMER.ALREADY_HAS_CARD'); - // } + + 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, @@ -132,6 +143,35 @@ export class NeoLeapService { ); } + 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( + 'cardcontrol/UpdateCardControl', + payload, + responseKey, + UpdateCardControlsResponseDto, + ); + } + private prepareHeaders(serviceName: string): INeoleapHeaderRequest['RequestHeader'] { return { Version: '1.0.0', @@ -156,7 +196,7 @@ export class NeoLeapService { }, }); - if (data.data?.ResponseHeader?.ResponseCode !== '000' || !data.data[responseKey]) { + if (data.data?.ResponseHeader?.ResponseCode !== '000') { throw new BadRequestException( data.data.ResponseHeader.ResponseDescription || 'Unexpected Error from Service Provider', ); diff --git a/src/customer/dtos/response/customer.response.dto.ts b/src/customer/dtos/response/customer.response.dto.ts index 3d40cf7..f49d12c 100644 --- a/src/customer/dtos/response/customer.response.dto.ts +++ b/src/customer/dtos/response/customer.response.dto.ts @@ -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; @@ -80,7 +98,12 @@ export class CustomerResponseDto { this.isJunior = customer.isJunior; this.isGuardian = customer.isGuardian; this.waitingNumber = customer.waitingNumber; - + 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; } } diff --git a/src/customer/services/customer.service.ts b/src/customer/services/customer.service.ts index dff93ff..5690858 100644 --- a/src/customer/services/customer.service.ts +++ b/src/customer/services/customer.service.ts @@ -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; @@ -202,4 +232,15 @@ export class CustomerService { 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}`; + } } diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index 1ce9b9e..754a4b5 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -43,7 +43,8 @@ }, "CUSTOMER": { "NOT_FOUND": "لم يتم العثور على العميل.", - "ALREADY_EXISTS": "العميل موجود بالفعل." + "ALREADY_EXISTS": "العميل موجود بالفعل.", + "KYC_NOT_APPROVED": "لم يتم الموافقة على هوية العميل بعد." }, "GIFT": { diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index 64b6c26..677a7df 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -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": { From bf43e62b17bf651ec35fa2d0905df731fe47f805 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 21 Jul 2025 15:30:55 +0300 Subject: [PATCH 12/24] feat: handle card status changed webhook --- src/card/entities/card.entity.ts | 8 +- .../enums/card-status-description.enum.ts | 68 +++++++++++ src/card/enums/index.ts | 1 + .../mappers/card-status-description.mapper.ts | 109 ++++++++++++++++++ src/card/mappers/card-status.mapper.ts | 37 ++++++ src/card/repositories/card.repository.ts | 10 +- src/card/services/card.service.ts | 9 ++ .../neoleap-webhooks.controller.ts | 19 ++- .../neoleap/controllers/neotest.controller.ts | 2 +- ...card-status-changed-webhook.request.dto.ts | 20 ++++ .../modules/neoleap/dtos/requests/index.ts | 1 + src/common/modules/neoleap/neoleap.module.ts | 3 +- src/common/modules/neoleap/services/index.ts | 2 + .../services/neoleap-webook.service.ts | 25 ++++ .../neoleap/services/neoleap.service.ts | 2 +- src/core/enums/user-locale.enum.ts | 12 +- .../dtos/response/customer.response.dto.ts | 2 +- src/customer/entities/customer.entity.ts | 4 +- .../1753098116701-update-card-table.ts | 16 +++ .../1753098326876-edit-customer-table.ts | 16 +++ src/db/migrations/index.ts | 2 + 21 files changed, 350 insertions(+), 18 deletions(-) create mode 100644 src/card/enums/card-status-description.enum.ts create mode 100644 src/card/mappers/card-status-description.mapper.ts create mode 100644 src/card/mappers/card-status.mapper.ts create mode 100644 src/common/modules/neoleap/dtos/requests/account-card-status-changed-webhook.request.dto.ts create mode 100644 src/common/modules/neoleap/services/neoleap-webook.service.ts create mode 100644 src/db/migrations/1753098116701-update-card-table.ts create mode 100644 src/db/migrations/1753098326876-edit-customer-table.ts diff --git a/src/card/entities/card.entity.ts b/src/card/entities/card.entity.ts index 4984a61..53ac864 100644 --- a/src/card/entities/card.entity.ts +++ b/src/card/entities/card.entity.ts @@ -10,7 +10,7 @@ import { UpdateDateColumn, } from 'typeorm'; import { Customer } from '~/customer/entities'; -import { CardColors, CardIssuers, CardScheme, CardStatus, CustomerType } from '../enums'; +import { CardColors, CardIssuers, CardScheme, CardStatus, CardStatusDescription, CustomerType } from '../enums'; import { Account } from './account.entity'; import { Transaction } from './transaction.entity'; @@ -41,6 +41,12 @@ export class Card { @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; diff --git a/src/card/enums/card-status-description.enum.ts b/src/card/enums/card-status-description.enum.ts new file mode 100644 index 0000000..1c277a8 --- /dev/null +++ b/src/card/enums/card-status-description.enum.ts @@ -0,0 +1,68 @@ +/** + * import { CardStatus, CardStatusDescription } from '../enums'; + + export const CardStatusMapper: Record = { + //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', +} diff --git a/src/card/enums/index.ts b/src/card/enums/index.ts index cac8b22..16b52c2 100644 --- a/src/card/enums/index.ts +++ b/src/card/enums/index.ts @@ -1,6 +1,7 @@ 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'; diff --git a/src/card/mappers/card-status-description.mapper.ts b/src/card/mappers/card-status-description.mapper.ts new file mode 100644 index 0000000..85fe6d9 --- /dev/null +++ b/src/card/mappers/card-status-description.mapper.ts @@ -0,0 +1,109 @@ +import { UserLocale } from '~/core/enums'; +import { CardStatusDescription } from '../enums'; + +export const CardStatusMapper: Record = { + [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: 'حالة البطاقة غير معروفة' }, + }, +}; diff --git a/src/card/mappers/card-status.mapper.ts b/src/card/mappers/card-status.mapper.ts new file mode 100644 index 0000000..3c6da90 --- /dev/null +++ b/src/card/mappers/card-status.mapper.ts @@ -0,0 +1,37 @@ +import { CardStatus, CardStatusDescription } from '../enums'; + +export const CardStatusMapper: Record = { + //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 }, +}; diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts index ebab1f7..d747feb 100644 --- a/src/card/repositories/card.repository.ts +++ b/src/card/repositories/card.repository.ts @@ -3,7 +3,7 @@ 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, CustomerType } from '../enums'; +import { CardColors, CardIssuers, CardScheme, CardStatus, CardStatusDescription, CustomerType } from '../enums'; @Injectable() export class CardRepository { @@ -19,7 +19,6 @@ export class CardRepository { firstSixDigits: card.firstSixDigits, lastFourDigits: card.lastFourDigits, color: CardColors.BLUE, - status: CardStatus.ACTIVE, scheme: CardScheme.VISA, issuer: CardIssuers.NEOLEAP, accountId: accountId, @@ -40,4 +39,11 @@ export class CardRepository { where: { customerId, status: CardStatus.ACTIVE }, }); } + + updateCardStatus(id: string, status: CardStatus, statusDescription: CardStatusDescription) { + return this.cardRepository.update(id, { + status: status, + statusDescription: statusDescription, + }); + } } diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index bc71cfd..61f785e 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -1,7 +1,9 @@ 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'; @@ -42,4 +44,11 @@ export class CardService { } 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); + } } diff --git a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts index 05d3bf9..1fb8d60 100644 --- a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts +++ b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts @@ -1,20 +1,29 @@ import { Body, Controller, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { TransactionService } from '~/card/services/transaction.service'; -import { AccountTransactionWebhookRequest, CardTransactionWebhookRequest } from '../dtos/requests'; +import { + AccountCardStatusChangedWebhookRequest, + AccountTransactionWebhookRequest, + CardTransactionWebhookRequest, +} from '../dtos/requests'; +import { NeoLeapWebhookService } from '../services'; @Controller('neoleap-webhooks') @ApiTags('Neoleap Webhooks') export class NeoLeapWebhooksController { - constructor(private readonly transactionService: TransactionService) {} + constructor(private readonly neoleapWebhookService: NeoLeapWebhookService) {} @Post('card-transaction') async handleCardTransactionWebhook(@Body() body: CardTransactionWebhookRequest) { - return this.transactionService.createCardTransaction(body); + return this.neoleapWebhookService.handleCardTransactionWebhook(body); } @Post('account-transaction') async handleAccountTransactionWebhook(@Body() body: AccountTransactionWebhookRequest) { - return this.transactionService.createAccountTransaction(body); + return this.neoleapWebhookService.handleAccountTransactionWebhook(body); + } + + @Post('account-card-status-changed') + async handleAccountCardStatusChangedWebhook(@Body() body: AccountCardStatusChangedWebhookRequest) { + return this.neoleapWebhookService.handleAccountCardStatusChangedWebhook(body); } } diff --git a/src/common/modules/neoleap/controllers/neotest.controller.ts b/src/common/modules/neoleap/controllers/neotest.controller.ts index 47ed777..5ea191d 100644 --- a/src/common/modules/neoleap/controllers/neotest.controller.ts +++ b/src/common/modules/neoleap/controllers/neotest.controller.ts @@ -37,7 +37,7 @@ export class NeoTestController { @ApiDataResponse(InquireApplicationResponse) async inquireApplication(@AuthenticatedUser() user: IJwtPayload) { const customer = await this.customerService.findCustomerById(user.sub); - const data = await this.neoleapService.inquireApplication(customer.waitingNumber.toString()); + const data = await this.neoleapService.inquireApplication(customer.applicationNumber.toString()); return ResponseFactory.data(data); } diff --git a/src/common/modules/neoleap/dtos/requests/account-card-status-changed-webhook.request.dto.ts b/src/common/modules/neoleap/dtos/requests/account-card-status-changed-webhook.request.dto.ts new file mode 100644 index 0000000..412effc --- /dev/null +++ b/src/common/modules/neoleap/dtos/requests/account-card-status-changed-webhook.request.dto.ts @@ -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; +} diff --git a/src/common/modules/neoleap/dtos/requests/index.ts b/src/common/modules/neoleap/dtos/requests/index.ts index d4cad6f..9360a77 100644 --- a/src/common/modules/neoleap/dtos/requests/index.ts +++ b/src/common/modules/neoleap/dtos/requests/index.ts @@ -1,3 +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'; diff --git a/src/common/modules/neoleap/neoleap.module.ts b/src/common/modules/neoleap/neoleap.module.ts index 78c0224..39951f0 100644 --- a/src/common/modules/neoleap/neoleap.module.ts +++ b/src/common/modules/neoleap/neoleap.module.ts @@ -4,11 +4,12 @@ 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], + providers: [NeoLeapService, NeoLeapWebhookService], }) export class NeoLeapModule {} diff --git a/src/common/modules/neoleap/services/index.ts b/src/common/modules/neoleap/services/index.ts index e69de29..4fac8d7 100644 --- a/src/common/modules/neoleap/services/index.ts +++ b/src/common/modules/neoleap/services/index.ts @@ -0,0 +1,2 @@ +export * from './neoleap-webook.service'; +export * from './neoleap.service'; diff --git a/src/common/modules/neoleap/services/neoleap-webook.service.ts b/src/common/modules/neoleap/services/neoleap-webook.service.ts new file mode 100644 index 0000000..83b68c4 --- /dev/null +++ b/src/common/modules/neoleap/services/neoleap-webook.service.ts @@ -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); + } +} diff --git a/src/common/modules/neoleap/services/neoleap.service.ts b/src/common/modules/neoleap/services/neoleap.service.ts index 6f31ac4..3abd065 100644 --- a/src/common/modules/neoleap/services/neoleap.service.ts +++ b/src/common/modules/neoleap/services/neoleap.service.ts @@ -50,7 +50,7 @@ export class NeoLeapService { CreateNewApplicationRequestDetails: { ApplicationRequestDetails: { InstitutionCode: this.institutionCode, - ExternalApplicationNumber: customer.waitingNumber.toString(), + ExternalApplicationNumber: customer.applicationNumber.toString(), ApplicationType: '01', Product: '1101', ApplicationDate: moment().format('YYYY-MM-DD'), diff --git a/src/core/enums/user-locale.enum.ts b/src/core/enums/user-locale.enum.ts index b9aa1f4..66339fe 100644 --- a/src/core/enums/user-locale.enum.ts +++ b/src/core/enums/user-locale.enum.ts @@ -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; diff --git a/src/customer/dtos/response/customer.response.dto.ts b/src/customer/dtos/response/customer.response.dto.ts index f49d12c..6475221 100644 --- a/src/customer/dtos/response/customer.response.dto.ts +++ b/src/customer/dtos/response/customer.response.dto.ts @@ -97,7 +97,7 @@ 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; diff --git a/src/customer/entities/customer.entity.ts b/src/customer/entities/customer.entity.ts index 96786c0..aa029ef 100644 --- a/src/customer/entities/customer.entity.ts +++ b/src/customer/entities/customer.entity.ts @@ -71,9 +71,9 @@ 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; diff --git a/src/db/migrations/1753098116701-update-card-table.ts b/src/db/migrations/1753098116701-update-card-table.ts new file mode 100644 index 0000000..56cb15c --- /dev/null +++ b/src/db/migrations/1753098116701-update-card-table.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UpdateCardTable1753098116701 implements MigrationInterface { + name = 'UpdateCardTable1753098116701' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "limit"`); + await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "statusDescription"`); + } + +} diff --git a/src/db/migrations/1753098326876-edit-customer-table.ts b/src/db/migrations/1753098326876-edit-customer-table.ts new file mode 100644 index 0000000..2595e2b --- /dev/null +++ b/src/db/migrations/1753098326876-edit-customer-table.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class EditCustomerTable1753098326876 implements MigrationInterface { + name = 'EditCustomerTable1753098326876' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } + +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 5efe087..a0238a5 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -29,3 +29,5 @@ 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'; From c493bd57e17ac093fc28bd9aca3ea72c4bb4aaf2 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 27 Jul 2025 13:15:54 +0300 Subject: [PATCH 13/24] feat: onboarding signup journey --- src/auth/auth.module.ts | 4 +- src/auth/controllers/auth.v2.controller.ts | 24 +++++++ src/auth/controllers/index.ts | 1 + .../create-unverified-user.request.v2.dto.ts | 4 ++ src/auth/dtos/request/index.ts | 2 + .../request/verify-user.v2.request.dto.ts | 71 +++++++++++++++++++ .../send-register-otp.v2.response.dto.ts | 10 +++ src/auth/services/auth.service.ts | 54 ++++++++++++++ src/i18n/ar/app.json | 3 +- src/i18n/en/app.json | 3 +- src/user/repositories/user.repository.ts | 3 +- src/user/services/user.service.ts | 65 +++++++++++++++-- 12 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 src/auth/controllers/auth.v2.controller.ts create mode 100644 src/auth/dtos/request/create-unverified-user.request.v2.dto.ts create mode 100644 src/auth/dtos/request/verify-user.v2.request.dto.ts create mode 100644 src/auth/dtos/response/send-register-otp.v2.response.dto.ts diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 138e689..60dd928 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -3,14 +3,14 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { JuniorModule } from '~/junior/junior.module'; import { UserModule } from '~/user/user.module'; -import { AuthController } from './controllers'; +import { AuthController, AuthV2Controller } from './controllers'; import { AuthService, Oauth2Service } from './services'; import { AccessTokenStrategy } from './strategies'; @Module({ imports: [JwtModule.register({}), UserModule, JuniorModule, HttpModule], providers: [AuthService, AccessTokenStrategy, Oauth2Service], - controllers: [AuthController], + controllers: [AuthController, AuthV2Controller], exports: [], }) export class AuthModule {} diff --git a/src/auth/controllers/auth.v2.controller.ts b/src/auth/controllers/auth.v2.controller.ts new file mode 100644 index 0000000..af8b147 --- /dev/null +++ b/src/auth/controllers/auth.v2.controller.ts @@ -0,0 +1,24 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ResponseFactory } from '~/core/utils'; +import { CreateUnverifiedUserV2RequestDto, VerifyUserV2RequestDto } from '../dtos/request'; +import { LoginResponseDto } from '../dtos/response/login.response.dto'; +import { SendRegisterOtpV2ResponseDto } from '../dtos/response/send-register-otp.v2.response.dto'; +import { AuthService } from '../services'; + +@Controller('auth/v2') +@ApiTags('Auth V2') +export class AuthV2Controller { + constructor(private readonly authService: AuthService) {} + @Post('register/otp') + async register(@Body() createUnverifiedUserDto: CreateUnverifiedUserV2RequestDto) { + const phoneNumber = await this.authService.sendPhoneRegisterOtp(createUnverifiedUserDto); + return ResponseFactory.data(new SendRegisterOtpV2ResponseDto(phoneNumber)); + } + + @Post('register/verify') + async verifyUser(@Body() verifyUserDto: VerifyUserV2RequestDto) { + const [loginResponse, user] = await this.authService.verifyUserV2(verifyUserDto); + return ResponseFactory.data(new LoginResponseDto(loginResponse, user)); + } +} diff --git a/src/auth/controllers/index.ts b/src/auth/controllers/index.ts index 04d02fa..37a52b8 100644 --- a/src/auth/controllers/index.ts +++ b/src/auth/controllers/index.ts @@ -1 +1,2 @@ export * from './auth.controller'; +export * from './auth.v2.controller'; diff --git a/src/auth/dtos/request/create-unverified-user.request.v2.dto.ts b/src/auth/dtos/request/create-unverified-user.request.v2.dto.ts new file mode 100644 index 0000000..7df2980 --- /dev/null +++ b/src/auth/dtos/request/create-unverified-user.request.v2.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { VerifyUserV2RequestDto } from './verify-user.v2.request.dto'; + +export class CreateUnverifiedUserV2RequestDto extends OmitType(VerifyUserV2RequestDto, ['otp']) {} diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts index d5c81cf..ab51125 100644 --- a/src/auth/dtos/request/index.ts +++ b/src/auth/dtos/request/index.ts @@ -1,5 +1,6 @@ export * from './apple-login.request.dto'; export * from './create-unverified-user.request.dto'; +export * from './create-unverified-user.request.v2.dto'; export * from './disable-biometric.request.dto'; export * from './enable-biometric.request.dto'; export * from './forget-password.request.dto'; @@ -14,3 +15,4 @@ export * from './set-passcode.request.dto'; export * from './verify-login-otp.request.dto'; export * from './verify-otp.request.dto'; export * from './verify-user.request.dto'; +export * from './verify-user.v2.request.dto'; diff --git a/src/auth/dtos/request/verify-user.v2.request.dto.ts b/src/auth/dtos/request/verify-user.v2.request.dto.ts new file mode 100644 index 0000000..ce0f216 --- /dev/null +++ b/src/auth/dtos/request/verify-user.v2.request.dto.ts @@ -0,0 +1,71 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsDateString, + IsEmail, + IsEnum, + IsNotEmpty, + IsNumberString, + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { COUNTRY_CODE_REGEX } from '~/auth/constants'; +import { CountryIso } from '~/common/enums'; +import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; +import { IsAbove18, IsValidPhoneNumber } from '~/core/decorators/validations'; + +export class VerifyUserV2RequestDto { + @ApiProperty({ example: '+962' }) + @Matches(COUNTRY_CODE_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), + }) + countryCode!: string; + + @ApiProperty({ example: '787259134' }) + @IsValidPhoneNumber({ + message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), + }) + phoneNumber!: string; + @ApiProperty({ example: 'John' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) + firstName!: string; + + @ApiProperty({ example: 'Doe' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) }) + @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) + lastName!: string; + + @ApiProperty({ example: '2021-01-01' }) + @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) + @IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) }) + dateOfBirth!: Date; + + @ApiProperty({ example: 'JO' }) + @IsEnum(CountryIso, { + message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }), + }) + @IsOptional() + countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA; + + @ApiProperty({ example: 'test@test.com' }) + @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) + @IsOptional() + email!: string; + + @ApiProperty({ example: '111111' }) + @IsNumberString( + { no_symbols: true }, + { message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) }, + ) + @MaxLength(DEFAULT_OTP_LENGTH, { + message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), + }) + @MinLength(DEFAULT_OTP_LENGTH, { + message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), + }) + otp!: string; +} diff --git a/src/auth/dtos/response/send-register-otp.v2.response.dto.ts b/src/auth/dtos/response/send-register-otp.v2.response.dto.ts new file mode 100644 index 0000000..27c72ec --- /dev/null +++ b/src/auth/dtos/response/send-register-otp.v2.response.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SendRegisterOtpV2ResponseDto { + @ApiProperty() + maskedNumber!: string; + + constructor(maskedNumber: string) { + this.maskedNumber = maskedNumber; + } +} diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 0d192b2..908e6ed 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -14,6 +14,7 @@ import { PASSCODE_REGEX } from '../constants'; import { AppleLoginRequestDto, CreateUnverifiedUserRequestDto, + CreateUnverifiedUserV2RequestDto, DisableBiometricRequestDto, EnableBiometricRequestDto, ForgetPasswordRequestDto, @@ -25,6 +26,7 @@ import { setJuniorPasswordRequestDto, VerifyLoginOtpRequestDto, VerifyUserRequestDto, + VerifyUserV2RequestDto, } from '../dtos/request'; import { Roles } from '../enums'; import { IJwtPayload, ILoginResponse } from '../interfaces'; @@ -59,6 +61,25 @@ export class AuthService { }); } + async sendPhoneRegisterOtp(body: CreateUnverifiedUserV2RequestDto) { + if (body.email) { + const isEmailUsed = await this.userService.findUser({ email: body.email, isEmailVerified: true }); + if (isEmailUsed) { + this.logger.error(`Email ${body.email} is already used`); + throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN'); + } + } + + this.logger.log(`Sending OTP to ${body.countryCode + body.phoneNumber}`); + const user = await this.userService.findOrCreateByPhoneNumber(body); + return this.otpService.generateAndSendOtp({ + userId: user.id, + recipient: user.fullPhoneNumber, + scope: OtpScope.VERIFY_PHONE, + otpType: OtpType.SMS, + }); + } + async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> { this.logger.log(`Verifying user with email ${verifyUserDto.email}`); const user = await this.userService.findUserOrThrow({ email: verifyUserDto.email }); @@ -89,6 +110,39 @@ export class AuthService { return [tokens, user]; } + async verifyUserV2(verifyUserDto: VerifyUserV2RequestDto): Promise<[ILoginResponse, User]> { + this.logger.log(`Verifying user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`); + const user = await this.userService.findUserOrThrow({ + phoneNumber: verifyUserDto.phoneNumber, + countryCode: verifyUserDto.countryCode, + }); + + if (user.isPhoneVerified) { + this.logger.error(`User with phone number ${user.fullPhoneNumber} already verified`); + throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_VERIFIED'); + } + + const isOtpValid = await this.otpService.verifyOtp({ + userId: user.id, + scope: OtpScope.VERIFY_PHONE, + otpType: OtpType.SMS, + value: verifyUserDto.otp, + }); + + if (!isOtpValid) { + this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`); + throw new BadRequestException('OTP.INVALID_OTP'); + } + + await this.userService.verifyUserV2(user.id, verifyUserDto); + + await user.reload(); + + const tokens = await this.generateAuthToken(user); + this.logger.log(`User with phone number ${user.fullPhoneNumber} verified successfully`); + return [tokens, user]; + } + async setEmail(userId: string, { email }: SetEmailRequestDto) { this.logger.log(`Setting email for user with id ${userId}`); const user = await this.userService.findUserOrThrow({ id: userId }); diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index 754a4b5..455ddaf 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -25,7 +25,8 @@ "ALREADY_EXISTS": "المستخدم موجود بالفعل.", "NOT_FOUND": "لم يتم العثور على المستخدم.", "PHONE_NUMBER_ALREADY_EXISTS": "رقم الهاتف موجود بالفعل.", - "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "ترقية الحساب من حساب طفل إلى حساب ولي أمر غير مدعومة حاليًا." + "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "ترقية الحساب من حساب طفل إلى حساب ولي أمر غير مدعومة حاليًا.", + "PHONE_NUMBER_ALREADY_TAKEN": "رقم الهاتف الذي أدخلته مستخدم بالفعل. يرجى استخدام رقم هاتف آخر." }, "ALLOWANCE": { diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index 677a7df..105afc9 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -25,7 +25,8 @@ "ALREADY_EXISTS": "The user already exists.", "NOT_FOUND": "The user was not found.", "PHONE_NUMBER_ALREADY_EXISTS": "The phone number already exists.", - "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "Upgrading account from junior to guardian is not yet supported." + "JUNIOR_UPGRADE_NOT_SUPPORTED_YET": "Upgrading account from junior to guardian is not yet supported.", + "PHONE_NUMBER_ALREADY_TAKEN": "The phone number you entered is already in use. Please use a different phone number." }, "ALLOWANCE": { "START_DATE_BEFORE_TODAY": "The start date cannot be before today.", diff --git a/src/user/repositories/user.repository.ts b/src/user/repositories/user.repository.ts index 79b8489..d740cd8 100644 --- a/src/user/repositories/user.repository.ts +++ b/src/user/repositories/user.repository.ts @@ -11,8 +11,7 @@ export class UserRepository { createUnverifiedUser(data: Partial) { return this.userRepository.save( this.userRepository.create({ - email: data.email, - roles: data.roles, + ...data, }), ); } diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index ec32953..3279780 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -7,7 +7,12 @@ import { Transactional } from 'typeorm-transactional'; import { CountryIso } from '~/common/enums'; import { NotificationsService } from '~/common/modules/notification/services'; import { CustomerService } from '~/customer/services'; -import { CreateUnverifiedUserRequestDto, VerifyUserRequestDto } from '../../auth/dtos/request'; +import { + CreateUnverifiedUserRequestDto, + CreateUnverifiedUserV2RequestDto, + VerifyUserRequestDto, + VerifyUserV2RequestDto, +} from '../../auth/dtos/request'; import { Roles } from '../../auth/enums'; import { CreateCheckerRequestDto, @@ -55,8 +60,8 @@ export class UserService { } @Transactional() - async verifyUser(userId: string, body: VerifyUserRequestDto) { - this.logger.log(`Verifying user email with id ${userId}`); + async verifyUser(userId: string, body: VerifyUserRequestDto | VerifyUserV2RequestDto) { + this.logger.log(`Verifying user with id ${userId}`); await Promise.all([ this.customerService.createGuardianCustomer(userId, { firstName: body.firstName, @@ -64,7 +69,27 @@ export class UserService { dateOfBirth: body.dateOfBirth, countryOfResidence: body.countryOfResidence, }), - this.userRepository.update(userId, { isEmailVerified: true }), + this.userRepository.update(userId, { + isEmailVerified: true, + }), + ]); + } + + @Transactional() + async verifyUserV2(userId: string, body: VerifyUserV2RequestDto) { + this.logger.log(`Verifying user with id ${userId}`); + await Promise.all([ + this.customerService.createGuardianCustomer(userId, { + firstName: body.firstName, + lastName: body.lastName, + dateOfBirth: body.dateOfBirth, + countryOfResidence: body.countryOfResidence, + }), + this.userRepository.update(userId, { + isPhoneVerified: true, + + ...(body.email && { email: body.email }), + }), ]); } @@ -110,6 +135,38 @@ export class UserService { return user; } + async findOrCreateByPhoneNumber(body: CreateUnverifiedUserV2RequestDto) { + this.logger.log(`Finding or creating user with phone number ${body.countryCode + body.phoneNumber}`); + const user = await this.userRepository.findOne({ + phoneNumber: body.phoneNumber, + countryCode: body.countryCode, + }); + + if (!user) { + this.logger.log(`User with phone number ${body.phoneNumber} not found, creating new user`); + return this.userRepository.createUnverifiedUser({ + phoneNumber: body.phoneNumber, + countryCode: body.countryCode, + email: body.email, + roles: [Roles.GUARDIAN], + }); + } + + if (user && user.roles.includes(Roles.GUARDIAN) && user.isPhoneVerified) { + this.logger.error(`User with phone number ${body.phoneNumber} already exists`); + throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_TAKEN'); + } + + if (user && user.roles.includes(Roles.JUNIOR)) { + this.logger.error(`User with phone number ${body.phoneNumber} is an already registered junior`); + throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); + //TODO add role Guardian to the existing user and send OTP + } + + this.logger.log(`User with phone number ${body.phoneNumber} found successfully`); + return user; + } + async findOrCreateByEmail(email: string) { this.logger.log(`Finding or creating user with email ${email} `); const user = await this.userRepository.findOne({ email }); From 1541c374edf9cec328f10d8f0ef3de3760512dcb Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 27 Jul 2025 13:26:21 +0300 Subject: [PATCH 14/24] feat: fix swagger examples --- src/auth/controllers/auth.v2.controller.ts | 3 ++ src/auth/dtos/response/login.response.dto.ts | 2 +- .../dtos/response/customer.response.dto.ts | 50 +++++++++---------- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/auth/controllers/auth.v2.controller.ts b/src/auth/controllers/auth.v2.controller.ts index af8b147..3b79641 100644 --- a/src/auth/controllers/auth.v2.controller.ts +++ b/src/auth/controllers/auth.v2.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { ApiDataResponse } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; import { CreateUnverifiedUserV2RequestDto, VerifyUserV2RequestDto } from '../dtos/request'; import { LoginResponseDto } from '../dtos/response/login.response.dto'; @@ -11,12 +12,14 @@ import { AuthService } from '../services'; export class AuthV2Controller { constructor(private readonly authService: AuthService) {} @Post('register/otp') + @ApiDataResponse(SendRegisterOtpV2ResponseDto) async register(@Body() createUnverifiedUserDto: CreateUnverifiedUserV2RequestDto) { const phoneNumber = await this.authService.sendPhoneRegisterOtp(createUnverifiedUserDto); return ResponseFactory.data(new SendRegisterOtpV2ResponseDto(phoneNumber)); } @Post('register/verify') + @ApiDataResponse(LoginResponseDto) async verifyUser(@Body() verifyUserDto: VerifyUserV2RequestDto) { const [loginResponse, user] = await this.authService.verifyUserV2(verifyUserDto); return ResponseFactory.data(new LoginResponseDto(loginResponse, user)); diff --git a/src/auth/dtos/response/login.response.dto.ts b/src/auth/dtos/response/login.response.dto.ts index 6e9ee15..6a64ef4 100644 --- a/src/auth/dtos/response/login.response.dto.ts +++ b/src/auth/dtos/response/login.response.dto.ts @@ -17,7 +17,7 @@ export class LoginResponseDto { @ApiProperty({ example: UserResponseDto }) user!: UserResponseDto; - @ApiProperty({ example: CustomerResponseDto }) + @ApiProperty({ type: CustomerResponseDto }) customer!: CustomerResponseDto | null; constructor(IVerifyUserResponse: ILoginResponse, user: User) { diff --git a/src/customer/dtos/response/customer.response.dto.ts b/src/customer/dtos/response/customer.response.dto.ts index 6475221..116b73a 100644 --- a/src/customer/dtos/response/customer.response.dto.ts +++ b/src/customer/dtos/response/customer.response.dto.ts @@ -1,79 +1,79 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Customer } from '~/customer/entities'; -import { CustomerStatus, KycStatus } from '~/customer/enums'; +import { CustomerStatus, Gender, KycStatus } from '~/customer/enums'; import { DocumentMetaResponseDto } from '~/document/dtos/response'; export class CustomerResponseDto { - @ApiProperty() + @ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' }) id!: string; - @ApiProperty() + @ApiProperty({ example: CustomerStatus.PENDING }) customerStatus!: CustomerStatus; - @ApiProperty() + @ApiProperty({ example: KycStatus.PENDING }) kycStatus!: KycStatus; - @ApiProperty() + @ApiProperty({ example: 'Rejection reason if any' }) rejectionReason!: string | null; - @ApiProperty() + @ApiProperty({ example: 'John' }) firstName!: string; - @ApiProperty() + @ApiProperty({ example: 'Doe' }) lastName!: string; - @ApiProperty() + @ApiProperty({ example: '1990-01-01' }) dateOfBirth!: Date; - @ApiProperty() + @ApiProperty({ example: '123456789' }) nationalId!: string; - @ApiProperty() + @ApiProperty({ example: '2025-01-01' }) nationalIdExpiry!: Date; - @ApiProperty() + @ApiProperty({ example: 'JO' }) countryOfResidence!: string; - @ApiProperty() + @ApiProperty({ example: 'Employee' }) sourceOfIncome!: string; - @ApiProperty() + @ApiProperty({ example: 'Software Development' }) profession!: string; - @ApiProperty() + @ApiProperty({ example: 'Full-time' }) professionType!: string; - @ApiProperty() + @ApiProperty({ example: false }) isPep!: boolean; - @ApiProperty() + @ApiProperty({ example: Gender.MALE }) gender!: string; - @ApiProperty() + @ApiProperty({ example: false }) isJunior!: boolean; - @ApiProperty() + @ApiProperty({ example: true }) isGuardian!: boolean; - @ApiProperty() + @ApiProperty({ example: 12345 }) waitingNumber!: number; - @ApiProperty() + @ApiProperty({ example: 'SA' }) country!: string | null; - @ApiProperty() + @ApiProperty({ example: 'Riyadh' }) region!: string | null; - @ApiProperty() + @ApiProperty({ example: 'Riyadh City' }) city!: string | null; - @ApiProperty() + @ApiProperty({ example: 'Al-Masif' }) neighborhood!: string | null; - @ApiProperty() + @ApiProperty({ example: 'King Fahd Road' }) street!: string | null; - @ApiProperty() + @ApiProperty({ example: '123' }) building!: string | null; @ApiPropertyOptional({ type: DocumentMetaResponseDto }) From 4cb5814cd31026d0dde1761f3eac5740d5cd3ea8 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Wed, 30 Jul 2025 14:18:10 +0300 Subject: [PATCH 15/24] fix: organize migrations --- ...9536067-add-address-fields-to-customers.ts | 23 ---------- .../1749633935436-create-card-entity.ts | 40 ----------------- .../1751456987627-create-account-entity.ts | 45 ------------------- .../1751466314709-create-transaction-table.ts | 18 -------- .../1752056898465-edit-transaction-table.ts | 16 ------- .../1753098116701-update-card-table.ts | 16 ------- .../1753098326876-edit-customer-table.ts | 16 ------- ...3874205042-add-neoleap-related-entities.ts | 44 ++++++++++++++++++ src/db/migrations/index.ts | 1 + 9 files changed, 45 insertions(+), 174 deletions(-) delete mode 100644 src/db/migrations/1747569536067-add-address-fields-to-customers.ts delete mode 100644 src/db/migrations/1749633935436-create-card-entity.ts delete mode 100644 src/db/migrations/1751456987627-create-account-entity.ts delete mode 100644 src/db/migrations/1751466314709-create-transaction-table.ts delete mode 100644 src/db/migrations/1752056898465-edit-transaction-table.ts delete mode 100644 src/db/migrations/1753098116701-update-card-table.ts delete mode 100644 src/db/migrations/1753098326876-edit-customer-table.ts create mode 100644 src/db/migrations/1753874205042-add-neoleap-related-entities.ts diff --git a/src/db/migrations/1747569536067-add-address-fields-to-customers.ts b/src/db/migrations/1747569536067-add-address-fields-to-customers.ts deleted file mode 100644 index 4d8a8ec..0000000 --- a/src/db/migrations/1747569536067-add-address-fields-to-customers.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class AddAddressFieldsToCustomers1747569536067 implements MigrationInterface { - name = 'AddAddressFieldsToCustomers1747569536067'; - - public async up(queryRunner: QueryRunner): Promise { - 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 { - 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"`); - } -} diff --git a/src/db/migrations/1749633935436-create-card-entity.ts b/src/db/migrations/1749633935436-create-card-entity.ts deleted file mode 100644 index 5a2ed55..0000000 --- a/src/db/migrations/1749633935436-create-card-entity.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CreateCardEntity1749633935436 implements MigrationInterface { - name = 'CreateCardEntity1749633935436'; - - public async up(queryRunner: QueryRunner): Promise { - 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 { - 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"`); - } -} diff --git a/src/db/migrations/1751456987627-create-account-entity.ts b/src/db/migrations/1751456987627-create-account-entity.ts deleted file mode 100644 index 40e7a1b..0000000 --- a/src/db/migrations/1751456987627-create-account-entity.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class CreateAccountEntity1751456987627 implements MigrationInterface { - name = 'CreateAccountEntity1751456987627'; - - public async up(queryRunner: QueryRunner): Promise { - 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 { - 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`, - ); - } -} diff --git a/src/db/migrations/1751466314709-create-transaction-table.ts b/src/db/migrations/1751466314709-create-transaction-table.ts deleted file mode 100644 index 4c5e773..0000000 --- a/src/db/migrations/1751466314709-create-transaction-table.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class CreateTransactionTable1751466314709 implements MigrationInterface { - name = 'CreateTransactionTable1751466314709' - - public async up(queryRunner: QueryRunner): Promise { - 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 { - 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"`); - } - -} diff --git a/src/db/migrations/1752056898465-edit-transaction-table.ts b/src/db/migrations/1752056898465-edit-transaction-table.ts deleted file mode 100644 index d4bab41..0000000 --- a/src/db/migrations/1752056898465-edit-transaction-table.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class EditTransactionTable1752056898465 implements MigrationInterface { - name = 'EditTransactionTable1752056898465' - - public async up(queryRunner: QueryRunner): Promise { - 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 { - await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "vat_on_fees"`); - await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "transaction_scope"`); - } - -} diff --git a/src/db/migrations/1753098116701-update-card-table.ts b/src/db/migrations/1753098116701-update-card-table.ts deleted file mode 100644 index 56cb15c..0000000 --- a/src/db/migrations/1753098116701-update-card-table.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class UpdateCardTable1753098116701 implements MigrationInterface { - name = 'UpdateCardTable1753098116701' - - public async up(queryRunner: QueryRunner): Promise { - 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 { - await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "limit"`); - await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "statusDescription"`); - } - -} diff --git a/src/db/migrations/1753098326876-edit-customer-table.ts b/src/db/migrations/1753098326876-edit-customer-table.ts deleted file mode 100644 index 2595e2b..0000000 --- a/src/db/migrations/1753098326876-edit-customer-table.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class EditCustomerTable1753098326876 implements MigrationInterface { - name = 'EditCustomerTable1753098326876' - - public async up(queryRunner: QueryRunner): Promise { - 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 { - 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"`); - } - -} diff --git a/src/db/migrations/1753874205042-add-neoleap-related-entities.ts b/src/db/migrations/1753874205042-add-neoleap-related-entities.ts new file mode 100644 index 0000000..ab80885 --- /dev/null +++ b/src/db/migrations/1753874205042-add-neoleap-related-entities.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddNeoleapRelatedEntities1753874205042 implements MigrationInterface { + name = 'AddNeoleapRelatedEntities1753874205042' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "transactions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "transaction_scope" character varying NOT NULL, "transaction_type" character varying NOT NULL DEFAULT 'EXTERNAL', "card_reference" character varying, "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, "vat_on_fees" numeric(12,2) NOT NULL DEFAULT '0', "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(`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(`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', "statusDescription" character varying NOT NULL DEFAULT 'PENDING_ACTIVATION', "limit" numeric(10,2) NOT NULL DEFAULT '0', "scheme" character varying NOT NULL DEFAULT 'VISA', "issuer" character varying NOT NULL, "customer_id" uuid NOT NULL, "parent_id" uuid, "account_id" uuid NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_5f3269634705fdff4a9935860fc" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_4f7df5c5dc950295dc417a11d8" ON "cards" ("card_reference") `); + 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)`); + 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`); + 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 { + 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(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_49c0d6e8ba4bfb5582000d851f0"`); + await queryRunner.query(`ALTER TABLE "transactions" DROP CONSTRAINT "FK_80ad48141be648db2d84ff32f79"`); + 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"`); + await queryRunner.query(`DROP INDEX "public"."IDX_4f7df5c5dc950295dc417a11d8"`); + await queryRunner.query(`DROP TABLE "cards"`); + await queryRunner.query(`DROP INDEX "public"."IDX_829183fe026a0ce5fc8026b241"`); + await queryRunner.query(`DROP TABLE "accounts"`); + await queryRunner.query(`DROP TABLE "transactions"`); + } + +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index e06880b..9996c42 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -2,3 +2,4 @@ export * from './1753869637732-seed-default-avatar'; export * from './1733990253208-seeds-default-tasks-logo'; export * from './1734247702310-seeds-goals-categories'; export * from './1733750228289-initial-migration'; +export * from './1753874205042-add-neoleap-related-entities'; From a245545811f8ddd1bd6728c98c7bed1d11df2795 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Wed, 30 Jul 2025 15:40:40 +0300 Subject: [PATCH 16/24] feat: add login and forget password and refactor code --- src/auth/auth.module.ts | 4 +- src/auth/controllers/auth.controller.ts | 124 ++---- src/auth/controllers/auth.v2.controller.ts | 27 -- src/auth/controllers/index.ts | 1 - .../create-unverified-user.request.dto.ts | 16 +- .../create-unverified-user.request.v2.dto.ts | 4 - .../request/forget-password.request.dto.ts | 34 +- src/auth/dtos/request/index.ts | 4 - src/auth/dtos/request/login.request.dto.ts | 47 +- .../send-forget-password-otp.request.dto.ts | 2 +- .../request/send-login-otp.request.dto.ts | 9 - .../request/verify-login-otp.request.dto.ts | 20 - .../dtos/request/verify-user.request.dto.ts | 38 +- .../request/verify-user.v2.request.dto.ts | 71 ---- .../send-forget-password.response.dto.ts | 6 +- .../send-register-otp.response.dto.ts | 6 +- src/auth/enums/grant-type.enum.ts | 2 - src/auth/services/auth.service.ts | 401 ++++-------------- src/user/services/user.service.ts | 76 +--- 19 files changed, 198 insertions(+), 694 deletions(-) delete mode 100644 src/auth/controllers/auth.v2.controller.ts delete mode 100644 src/auth/dtos/request/create-unverified-user.request.v2.dto.ts delete mode 100644 src/auth/dtos/request/send-login-otp.request.dto.ts delete mode 100644 src/auth/dtos/request/verify-login-otp.request.dto.ts delete mode 100644 src/auth/dtos/request/verify-user.v2.request.dto.ts diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 60dd928..138e689 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -3,14 +3,14 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { JuniorModule } from '~/junior/junior.module'; import { UserModule } from '~/user/user.module'; -import { AuthController, AuthV2Controller } from './controllers'; +import { AuthController } from './controllers'; import { AuthService, Oauth2Service } from './services'; import { AccessTokenStrategy } from './strategies'; @Module({ imports: [JwtModule.register({}), UserModule, JuniorModule, HttpModule], providers: [AuthService, AccessTokenStrategy, Oauth2Service], - controllers: [AuthController, AuthV2Controller], + controllers: [AuthController], exports: [], }) export class AuthModule {} diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index d402ad6..f651e69 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -1,29 +1,20 @@ import { Body, Controller, HttpCode, HttpStatus, Post, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; -import { AuthenticatedUser, Public } from '~/common/decorators'; +import { Public } from '~/common/decorators'; import { AccessTokenGuard } from '~/common/guards'; -import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; +import { ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; import { - AppleLoginRequestDto, CreateUnverifiedUserRequestDto, - DisableBiometricRequestDto, - EnableBiometricRequestDto, ForgetPasswordRequestDto, - GoogleLoginRequestDto, + LoginRequestDto, RefreshTokenRequestDto, SendForgetPasswordOtpRequestDto, - SendLoginOtpRequestDto, - SetEmailRequestDto, - setJuniorPasswordRequestDto, - SetPasscodeRequestDto, - VerifyLoginOtpRequestDto, VerifyUserRequestDto, } from '../dtos/request'; import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response'; import { LoginResponseDto } from '../dtos/response/login.response.dto'; -import { IJwtPayload } from '../interfaces'; import { AuthService } from '../services'; @Controller('auth') @@ -44,85 +35,16 @@ export class AuthController { return ResponseFactory.data(new LoginResponseDto(res, user)); } - @Post('login/otp') - @HttpCode(HttpStatus.NO_CONTENT) - async sendLoginOtp(@Body() data: SendLoginOtpRequestDto) { - return this.authService.sendLoginOtp(data); - } - - @Post('login/verify') - @HttpCode(HttpStatus.OK) - @ApiDataResponse(LoginResponseDto) - async verifyLoginOtp(@Body() data: VerifyLoginOtpRequestDto) { - const [token, user] = await this.authService.verifyLoginOtp(data); - return ResponseFactory.data(new LoginResponseDto(token, user)); - } - - @Post('login/google') - @HttpCode(HttpStatus.OK) - @ApiDataResponse(LoginResponseDto) - async loginWithGoogle(@Body() data: GoogleLoginRequestDto) { - const [token, user] = await this.authService.loginWithGoogle(data); - return ResponseFactory.data(new LoginResponseDto(token, user)); - } - - @Post('login/apple') - @HttpCode(HttpStatus.OK) - @ApiDataResponse(LoginResponseDto) - async loginWithApple(@Body() data: AppleLoginRequestDto) { - const [token, user] = await this.authService.loginWithApple(data); - return ResponseFactory.data(new LoginResponseDto(token, user)); - } - - @Post('register/set-email') - @HttpCode(HttpStatus.NO_CONTENT) - @UseGuards(AccessTokenGuard) - async setEmail(@AuthenticatedUser() { sub }: IJwtPayload, @Body() setEmailDto: SetEmailRequestDto) { - await this.authService.setEmail(sub, setEmailDto); - } - - @Post('register/set-passcode') - @HttpCode(HttpStatus.NO_CONTENT) - @UseGuards(AccessTokenGuard) - async setPasscode(@AuthenticatedUser() { sub }: IJwtPayload, @Body() { passcode }: SetPasscodeRequestDto) { - await this.authService.setPasscode(sub, passcode); - } - - // @Post('register/set-phone/otp') - // @UseGuards(AccessTokenGuard) - // async setPhoneNumber( - // @AuthenticatedUser() { sub }: IJwtPayload, - // @Body() setPhoneNumberDto: CreateUnverifiedUserRequestDto, - // ) { - // const phoneNumber = await this.authService.setPhoneNumber(sub, setPhoneNumberDto); - // return ResponseFactory.data(new SendRegisterOtpResponseDto(phoneNumber)); - // } - - // @Post('register/set-phone/verify') - // @HttpCode(HttpStatus.NO_CONTENT) - // @UseGuards(AccessTokenGuard) - // async verifyPhoneNumber(@AuthenticatedUser() { sub }: IJwtPayload, @Body() { otp }: VerifyOtpRequestDto) { - // await this.authService.verifyPhoneNumber(sub, otp); - // } - - @Post('biometric/enable') - @HttpCode(HttpStatus.NO_CONTENT) - @UseGuards(AccessTokenGuard) - enableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() enableBiometricDto: EnableBiometricRequestDto) { - return this.authService.enableBiometric(sub, enableBiometricDto); - } - - @Post('biometric/disable') - @HttpCode(HttpStatus.NO_CONTENT) - @UseGuards(AccessTokenGuard) - disableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() disableBiometricDto: DisableBiometricRequestDto) { - return this.authService.disableBiometric(sub, disableBiometricDto); + @Post('login') + async login(@Body() verifyUserDto: LoginRequestDto) { + const [res, user] = await this.authService.loginWithPassword(verifyUserDto); + return ResponseFactory.data(new LoginResponseDto(res, user)); } @Post('forget-password/otp') async forgetPassword(@Body() sendForgetPasswordOtpDto: SendForgetPasswordOtpRequestDto) { - const email = await this.authService.sendForgetPasswordOtp(sendForgetPasswordOtpDto); - return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(email)); + const maskedNumber = await this.authService.sendForgetPasswordOtp(sendForgetPasswordOtpDto); + return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(maskedNumber)); } @Post('forget-password/reset') @@ -131,13 +53,6 @@ export class AuthController { return this.authService.verifyForgetPasswordOtp(forgetPasswordDto); } - @Post('junior/set-passcode') - @HttpCode(HttpStatus.NO_CONTENT) - @Public() - setJuniorPasscode(@Body() setPasscodeDto: setJuniorPasswordRequestDto) { - return this.authService.setJuniorPasscode(setPasscodeDto); - } - @Post('refresh-token') @Public() async refreshToken(@Body() { refreshToken }: RefreshTokenRequestDto) { @@ -151,4 +66,25 @@ export class AuthController { async logout(@Req() request: Request) { await this.authService.logout(request); } + + // @Post('biometric/enable') + // @HttpCode(HttpStatus.NO_CONTENT) + // @UseGuards(AccessTokenGuard) + // enableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() enableBiometricDto: EnableBiometricRequestDto) { + // return this.authService.enableBiometric(sub, enableBiometricDto); + // } + + // @Post('biometric/disable') + // @HttpCode(HttpStatus.NO_CONTENT) + // @UseGuards(AccessTokenGuard) + // disableBiometric(@AuthenticatedUser() { sub }: IJwtPayload, @Body() disableBiometricDto: DisableBiometricRequestDto) { + // return this.authService.disableBiometric(sub, disableBiometricDto); + // } + + // @Post('junior/set-passcode') + // @HttpCode(HttpStatus.NO_CONTENT) + // @Public() + // setJuniorPasscode(@Body() setPasscodeDto: setJuniorPasswordRequestDto) { + // return this.authService.setJuniorPasscode(setPasscodeDto); + // } } diff --git a/src/auth/controllers/auth.v2.controller.ts b/src/auth/controllers/auth.v2.controller.ts deleted file mode 100644 index 3b79641..0000000 --- a/src/auth/controllers/auth.v2.controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Body, Controller, Post } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { ApiDataResponse } from '~/core/decorators'; -import { ResponseFactory } from '~/core/utils'; -import { CreateUnverifiedUserV2RequestDto, VerifyUserV2RequestDto } from '../dtos/request'; -import { LoginResponseDto } from '../dtos/response/login.response.dto'; -import { SendRegisterOtpV2ResponseDto } from '../dtos/response/send-register-otp.v2.response.dto'; -import { AuthService } from '../services'; - -@Controller('auth/v2') -@ApiTags('Auth V2') -export class AuthV2Controller { - constructor(private readonly authService: AuthService) {} - @Post('register/otp') - @ApiDataResponse(SendRegisterOtpV2ResponseDto) - async register(@Body() createUnverifiedUserDto: CreateUnverifiedUserV2RequestDto) { - const phoneNumber = await this.authService.sendPhoneRegisterOtp(createUnverifiedUserDto); - return ResponseFactory.data(new SendRegisterOtpV2ResponseDto(phoneNumber)); - } - - @Post('register/verify') - @ApiDataResponse(LoginResponseDto) - async verifyUser(@Body() verifyUserDto: VerifyUserV2RequestDto) { - const [loginResponse, user] = await this.authService.verifyUserV2(verifyUserDto); - return ResponseFactory.data(new LoginResponseDto(loginResponse, user)); - } -} diff --git a/src/auth/controllers/index.ts b/src/auth/controllers/index.ts index 37a52b8..04d02fa 100644 --- a/src/auth/controllers/index.ts +++ b/src/auth/controllers/index.ts @@ -1,2 +1 @@ export * from './auth.controller'; -export * from './auth.v2.controller'; diff --git a/src/auth/dtos/request/create-unverified-user.request.dto.ts b/src/auth/dtos/request/create-unverified-user.request.dto.ts index 9fdc3bd..520f99e 100644 --- a/src/auth/dtos/request/create-unverified-user.request.dto.ts +++ b/src/auth/dtos/request/create-unverified-user.request.dto.ts @@ -1,14 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { OmitType } from '@nestjs/swagger'; +import { VerifyUserRequestDto } from './verify-user.request.dto'; -export class CreateUnverifiedUserRequestDto { - @ApiProperty({ example: 'test@test.com' }) - @IsEmail( - {}, - { - message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }), - }, - ) - email!: string; -} +export class CreateUnverifiedUserRequestDto extends OmitType(VerifyUserRequestDto, ['otp']) {} diff --git a/src/auth/dtos/request/create-unverified-user.request.v2.dto.ts b/src/auth/dtos/request/create-unverified-user.request.v2.dto.ts deleted file mode 100644 index 7df2980..0000000 --- a/src/auth/dtos/request/create-unverified-user.request.v2.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { OmitType } from '@nestjs/swagger'; -import { VerifyUserV2RequestDto } from './verify-user.v2.request.dto'; - -export class CreateUnverifiedUserV2RequestDto extends OmitType(VerifyUserV2RequestDto, ['otp']) {} diff --git a/src/auth/dtos/request/forget-password.request.dto.ts b/src/auth/dtos/request/forget-password.request.dto.ts index 97f7236..4153a96 100644 --- a/src/auth/dtos/request/forget-password.request.dto.ts +++ b/src/auth/dtos/request/forget-password.request.dto.ts @@ -1,20 +1,32 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, IsNumberString, IsString, MaxLength, MinLength } from 'class-validator'; +import { ApiProperty, PickType } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsNumberString, IsString, Matches, MaxLength, MinLength } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants'; import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; +import { IsValidPhoneNumber } from '~/core/decorators/validations'; export class ForgetPasswordRequestDto { - @ApiProperty({ example: 'test@test.com' }) - @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) - email!: string; + @ApiProperty({ example: '+962' }) + @Matches(COUNTRY_CODE_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), + }) + countryCode!: string; - @ApiProperty({ example: 'password' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.password' }) }) + @ApiProperty({ example: '787259134' }) + @IsValidPhoneNumber({ + message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), + }) + phoneNumber!: string; + + @ApiProperty({ example: 'Abcd1234@' }) + @Matches(PASSWORD_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.password' }), + }) password!: string; - @ApiProperty({ example: 'password' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.confirmPassword' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.confirmPassword' }) }) + @ApiProperty({ example: 'Abcd1234@' }) + @Matches(PASSWORD_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.confirmPassword' }), + }) confirmPassword!: string; @ApiProperty({ example: '111111' }) diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts index ab51125..a329e46 100644 --- a/src/auth/dtos/request/index.ts +++ b/src/auth/dtos/request/index.ts @@ -1,6 +1,5 @@ export * from './apple-login.request.dto'; export * from './create-unverified-user.request.dto'; -export * from './create-unverified-user.request.v2.dto'; export * from './disable-biometric.request.dto'; export * from './enable-biometric.request.dto'; export * from './forget-password.request.dto'; @@ -8,11 +7,8 @@ export * from './google-login.request.dto'; export * from './login.request.dto'; export * from './refresh-token.request.dto'; export * from './send-forget-password-otp.request.dto'; -export * from './send-login-otp.request.dto'; export * from './set-email.request.dto'; export * from './set-junior-password.request.dto'; export * from './set-passcode.request.dto'; -export * from './verify-login-otp.request.dto'; export * from './verify-otp.request.dto'; export * from './verify-user.request.dto'; -export * from './verify-user.v2.request.dto'; diff --git a/src/auth/dtos/request/login.request.dto.ts b/src/auth/dtos/request/login.request.dto.ts index 476961d..ed48ae3 100644 --- a/src/auth/dtos/request/login.request.dto.ts +++ b/src/auth/dtos/request/login.request.dto.ts @@ -1,43 +1,24 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, ValidateIf } from 'class-validator'; +import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, Matches, ValidateIf } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { COUNTRY_CODE_REGEX } from '~/auth/constants'; import { GrantType } from '~/auth/enums'; +import { IsValidPhoneNumber } from '~/core/decorators/validations'; export class LoginRequestDto { - @ApiProperty({ example: GrantType.APPLE }) - @IsEnum(GrantType, { message: i18n('validation.IsEnum', { path: 'general', property: 'auth.grantType' }) }) - grantType!: GrantType; + @ApiProperty({ example: '+962' }) + @Matches(COUNTRY_CODE_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), + }) + countryCode!: string; - @ApiProperty({ example: 'test@test.com' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.email' }) }) - @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) - @ValidateIf((o) => o.grantType !== GrantType.APPLE && o.grantType !== GrantType.GOOGLE) - email!: string; + @ApiProperty({ example: '787259134' }) + @IsValidPhoneNumber({ + message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), + }) + phoneNumber!: string; - @ApiProperty({ example: '123456' }) + @ApiProperty({ example: 'Abcd1234@' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.password' }) }) @ValidateIf((o) => o.grantType === GrantType.PASSWORD) password!: string; - - @ApiProperty({ example: 'Login signature' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.signature' }) }) - @ValidateIf((o) => o.grantType === GrantType.BIOMETRIC) - signature!: string; - - @ApiProperty({ example: 'google_token' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.googleToken' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.googleToken' }) }) - @ValidateIf((o) => o.grantType === GrantType.GOOGLE) - googleToken!: string; - - @ApiProperty({ example: 'apple_token' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.appleToken' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.appleToken' }) }) - @ValidateIf((o) => o.grantType === GrantType.APPLE) - appleToken!: string; - - @ApiProperty({ example: 'fcm-device-token' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.fcmToken' }) }) - @IsOptional() - fcmToken?: string; } diff --git a/src/auth/dtos/request/send-forget-password-otp.request.dto.ts b/src/auth/dtos/request/send-forget-password-otp.request.dto.ts index 077b14a..ebb7941 100644 --- a/src/auth/dtos/request/send-forget-password-otp.request.dto.ts +++ b/src/auth/dtos/request/send-forget-password-otp.request.dto.ts @@ -1,4 +1,4 @@ import { PickType } from '@nestjs/swagger'; import { LoginRequestDto } from './login.request.dto'; -export class SendForgetPasswordOtpRequestDto extends PickType(LoginRequestDto, ['email']) {} +export class SendForgetPasswordOtpRequestDto extends PickType(LoginRequestDto, ['countryCode', 'phoneNumber']) {} diff --git a/src/auth/dtos/request/send-login-otp.request.dto.ts b/src/auth/dtos/request/send-login-otp.request.dto.ts deleted file mode 100644 index 45388f3..0000000 --- a/src/auth/dtos/request/send-login-otp.request.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; - -export class SendLoginOtpRequestDto { - @ApiProperty({ example: 'test@test.com' }) - @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) - email!: string; -} diff --git a/src/auth/dtos/request/verify-login-otp.request.dto.ts b/src/auth/dtos/request/verify-login-otp.request.dto.ts deleted file mode 100644 index 819ead5..0000000 --- a/src/auth/dtos/request/verify-login-otp.request.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNumberString, MaxLength, MinLength } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; -import { SendLoginOtpRequestDto } from './send-login-otp.request.dto'; - -export class VerifyLoginOtpRequestDto extends SendLoginOtpRequestDto { - @ApiProperty({ example: '111111' }) - @IsNumberString( - { no_symbols: true }, - { message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) }, - ) - @MaxLength(DEFAULT_OTP_LENGTH, { - message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), - }) - @MinLength(DEFAULT_OTP_LENGTH, { - message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), - }) - otp!: string; -} diff --git a/src/auth/dtos/request/verify-user.request.dto.ts b/src/auth/dtos/request/verify-user.request.dto.ts index c1adc87..f0d7289 100644 --- a/src/auth/dtos/request/verify-user.request.dto.ts +++ b/src/auth/dtos/request/verify-user.request.dto.ts @@ -1,21 +1,34 @@ -import { ApiProperty, PickType } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { IsDateString, + IsEmail, IsEnum, IsNotEmpty, IsNumberString, IsOptional, IsString, + Matches, MaxLength, MinLength, } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants'; import { CountryIso } from '~/common/enums'; import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; -import { IsAbove18 } from '~/core/decorators/validations'; -import { CreateUnverifiedUserRequestDto } from './create-unverified-user.request.dto'; +import { IsAbove18, IsValidPhoneNumber } from '~/core/decorators/validations'; -export class VerifyUserRequestDto extends PickType(CreateUnverifiedUserRequestDto, ['email']) { +export class VerifyUserRequestDto { + @ApiProperty({ example: '+962' }) + @Matches(COUNTRY_CODE_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), + }) + countryCode!: string; + + @ApiProperty({ example: '787259134' }) + @IsValidPhoneNumber({ + message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), + }) + phoneNumber!: string; @ApiProperty({ example: 'John' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) @@ -38,6 +51,23 @@ export class VerifyUserRequestDto extends PickType(CreateUnverifiedUserRequestDt @IsOptional() countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA; + @ApiProperty({ example: 'test@test.com' }) + @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) + @IsOptional() + email!: string; + + @ApiProperty({ example: 'Abcd1234@' }) + @Matches(PASSWORD_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.password' }), + }) + password!: string; + + @ApiProperty({ example: 'Abcd1234@' }) + @Matches(PASSWORD_REGEX, { + message: i18n('validation.Matches', { path: 'general', property: 'auth.confirmPassword' }), + }) + confirmPassword!: string; + @ApiProperty({ example: '111111' }) @IsNumberString( { no_symbols: true }, diff --git a/src/auth/dtos/request/verify-user.v2.request.dto.ts b/src/auth/dtos/request/verify-user.v2.request.dto.ts deleted file mode 100644 index ce0f216..0000000 --- a/src/auth/dtos/request/verify-user.v2.request.dto.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { - IsDateString, - IsEmail, - IsEnum, - IsNotEmpty, - IsNumberString, - IsOptional, - IsString, - Matches, - MaxLength, - MinLength, -} from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { COUNTRY_CODE_REGEX } from '~/auth/constants'; -import { CountryIso } from '~/common/enums'; -import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; -import { IsAbove18, IsValidPhoneNumber } from '~/core/decorators/validations'; - -export class VerifyUserV2RequestDto { - @ApiProperty({ example: '+962' }) - @Matches(COUNTRY_CODE_REGEX, { - message: i18n('validation.Matches', { path: 'general', property: 'auth.countryCode' }), - }) - countryCode!: string; - - @ApiProperty({ example: '787259134' }) - @IsValidPhoneNumber({ - message: i18n('validation.IsValidPhoneNumber', { path: 'general', property: 'auth.phoneNumber' }), - }) - phoneNumber!: string; - @ApiProperty({ example: 'John' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.firstName' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.firstName' }) }) - firstName!: string; - - @ApiProperty({ example: 'Doe' }) - @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'customer.lastName' }) }) - @IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'customer.lastName' }) }) - lastName!: string; - - @ApiProperty({ example: '2021-01-01' }) - @IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) }) - @IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) }) - dateOfBirth!: Date; - - @ApiProperty({ example: 'JO' }) - @IsEnum(CountryIso, { - message: i18n('validation.IsEnum', { path: 'general', property: 'customer.countryOfResidence' }), - }) - @IsOptional() - countryOfResidence: CountryIso = CountryIso.SAUDI_ARABIA; - - @ApiProperty({ example: 'test@test.com' }) - @IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'auth.email' }) }) - @IsOptional() - email!: string; - - @ApiProperty({ example: '111111' }) - @IsNumberString( - { no_symbols: true }, - { message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) }, - ) - @MaxLength(DEFAULT_OTP_LENGTH, { - message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), - }) - @MinLength(DEFAULT_OTP_LENGTH, { - message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), - }) - otp!: string; -} diff --git a/src/auth/dtos/response/send-forget-password.response.dto.ts b/src/auth/dtos/response/send-forget-password.response.dto.ts index 1bbf6b8..40e338f 100644 --- a/src/auth/dtos/response/send-forget-password.response.dto.ts +++ b/src/auth/dtos/response/send-forget-password.response.dto.ts @@ -1,7 +1,7 @@ export class SendForgetPasswordOtpResponseDto { - email!: string; + maskedNumber!: string; - constructor(email: string) { - this.email = email; + constructor(maskedNumber: string) { + this.maskedNumber = maskedNumber; } } diff --git a/src/auth/dtos/response/send-register-otp.response.dto.ts b/src/auth/dtos/response/send-register-otp.response.dto.ts index 0597b90..ee27ad5 100644 --- a/src/auth/dtos/response/send-register-otp.response.dto.ts +++ b/src/auth/dtos/response/send-register-otp.response.dto.ts @@ -2,9 +2,9 @@ import { ApiProperty } from '@nestjs/swagger'; export class SendRegisterOtpResponseDto { @ApiProperty() - email!: string; + maskedNumber!: string; - constructor(email: string) { - this.email = email; + constructor(maskedNumber: string) { + this.maskedNumber = maskedNumber; } } diff --git a/src/auth/enums/grant-type.enum.ts b/src/auth/enums/grant-type.enum.ts index 952f851..4a16d92 100644 --- a/src/auth/enums/grant-type.enum.ts +++ b/src/auth/enums/grant-type.enum.ts @@ -1,6 +1,4 @@ export enum GrantType { PASSWORD = 'PASSWORD', BIOMETRIC = 'BIOMETRIC', - GOOGLE = 'GOOGLE', - APPLE = 'APPLE', } diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 908e6ed..ca5e4dd 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -3,7 +3,6 @@ import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { Request } from 'express'; -import { ArrayContains } from 'typeorm'; import { CacheService } from '~/common/modules/cache/services'; import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpService } from '~/common/modules/otp/services'; @@ -12,23 +11,15 @@ import { DeviceService, UserService, UserTokenService } from '~/user/services'; import { User } from '../../user/entities'; import { PASSCODE_REGEX } from '../constants'; import { - AppleLoginRequestDto, CreateUnverifiedUserRequestDto, - CreateUnverifiedUserV2RequestDto, DisableBiometricRequestDto, EnableBiometricRequestDto, ForgetPasswordRequestDto, - GoogleLoginRequestDto, LoginRequestDto, SendForgetPasswordOtpRequestDto, - SendLoginOtpRequestDto, - SetEmailRequestDto, setJuniorPasswordRequestDto, - VerifyLoginOtpRequestDto, VerifyUserRequestDto, - VerifyUserV2RequestDto, } from '../dtos/request'; -import { Roles } from '../enums'; import { IJwtPayload, ILoginResponse } from '../interfaces'; import { removePadding, verifySignature } from '../utils'; import { Oauth2Service } from './oauth2.service'; @@ -49,19 +40,8 @@ export class AuthService { private readonly cacheService: CacheService, private readonly oauth2Service: Oauth2Service, ) {} + async sendRegisterOtp(body: CreateUnverifiedUserRequestDto) { - this.logger.log(`Sending OTP to ${body.email}`); - const user = await this.userService.findOrCreateUser(body); - - return this.otpService.generateAndSendOtp({ - userId: user.id, - recipient: user.email, - scope: OtpScope.VERIFY_EMAIL, - otpType: OtpType.EMAIL, - }); - } - - async sendPhoneRegisterOtp(body: CreateUnverifiedUserV2RequestDto) { if (body.email) { const isEmailUsed = await this.userService.findUser({ email: body.email, isEmailVerified: true }); if (isEmailUsed) { @@ -70,8 +50,13 @@ export class AuthService { } } + if (body.password !== body.confirmPassword) { + this.logger.error('Password and confirm password do not match'); + throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); + } + this.logger.log(`Sending OTP to ${body.countryCode + body.phoneNumber}`); - const user = await this.userService.findOrCreateByPhoneNumber(body); + const user = await this.userService.findOrCreateUser(body); return this.otpService.generateAndSendOtp({ userId: user.id, recipient: user.fullPhoneNumber, @@ -81,36 +66,6 @@ export class AuthService { } async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> { - this.logger.log(`Verifying user with email ${verifyUserDto.email}`); - const user = await this.userService.findUserOrThrow({ email: verifyUserDto.email }); - - if (user.isEmailVerified) { - this.logger.error(`User with email ${verifyUserDto.email} already verified`); - throw new BadRequestException('USER.EMAIL_ALREADY_VERIFIED'); - } - - const isOtpValid = await this.otpService.verifyOtp({ - userId: user.id, - scope: OtpScope.VERIFY_EMAIL, - otpType: OtpType.EMAIL, - value: verifyUserDto.otp, - }); - - if (!isOtpValid) { - this.logger.error(`Invalid OTP for user with email ${verifyUserDto.email}`); - throw new BadRequestException('OTP.INVALID_OTP'); - } - - await this.userService.verifyUser(user.id, verifyUserDto); - - await user.reload(); - - const tokens = await this.generateAuthToken(user); - this.logger.log(`User with email ${verifyUserDto.email} verified successfully`); - return [tokens, user]; - } - - async verifyUserV2(verifyUserDto: VerifyUserV2RequestDto): Promise<[ILoginResponse, User]> { this.logger.log(`Verifying user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`); const user = await this.userService.findUserOrThrow({ phoneNumber: verifyUserDto.phoneNumber, @@ -134,7 +89,7 @@ export class AuthService { throw new BadRequestException('OTP.INVALID_OTP'); } - await this.userService.verifyUserV2(user.id, verifyUserDto); + await this.userService.verifyUser(user.id, verifyUserDto); await user.reload(); @@ -143,81 +98,6 @@ export class AuthService { return [tokens, user]; } - async setEmail(userId: string, { email }: SetEmailRequestDto) { - this.logger.log(`Setting email for user with id ${userId}`); - const user = await this.userService.findUserOrThrow({ id: userId }); - - if (user.email) { - this.logger.error(`Email already set for user with id ${userId}`); - throw new BadRequestException('USER.EMAIL_ALREADY_SET'); - } - - const existingUser = await this.userService.findUser({ email }); - - if (existingUser) { - this.logger.error(`Email ${email} already taken`); - throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN'); - } - - return this.userService.setEmail(userId, email); - } - - async setPasscode(userId: string, passcode: string) { - this.logger.log(`Setting passcode for user with id ${userId}`); - const user = await this.userService.findUserOrThrow({ id: userId }); - - if (user.password) { - this.logger.error(`Passcode already set for user with id ${userId}`); - throw new BadRequestException('AUTH.PASSCODE_ALREADY_SET'); - } - const salt = bcrypt.genSaltSync(SALT_ROUNDS); - const hashedPasscode = bcrypt.hashSync(passcode, salt); - - await this.userService.setPasscode(userId, hashedPasscode, salt); - this.logger.log(`Passcode set successfully for user with id ${userId}`); - } - - // async setPhoneNumber(userId: string, { phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) { - // const user = await this.userService.findUserOrThrow({ id: userId }); - - // if (user.phoneNumber || user.countryCode) { - // this.logger.error(`Phone number already set for user with id ${userId}`); - // throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_SET'); - // } - - // const existingUser = await this.userService.findUser({ phoneNumber, countryCode }); - - // if (existingUser) { - // this.logger.error(`Phone number ${countryCode + phoneNumber} already taken`); - // throw new BadRequestException('USER.PHONE_NUMBER_ALREADY_TAKEN'); - // } - - // await this.userService.setPhoneNumber(userId, phoneNumber, countryCode); - - // return this.otpService.generateAndSendOtp({ - // userId, - // recipient: countryCode + phoneNumber, - // scope: OtpScope.VERIFY_PHONE, - // otpType: OtpType.SMS, - // }); - // } - - // async verifyPhoneNumber(userId: string, otp: string) { - // const isOtpValid = await this.otpService.verifyOtp({ - // otpType: OtpType.SMS, - // scope: OtpScope.VERIFY_PHONE, - // userId, - // value: otp, - // }); - - // if (!isOtpValid) { - // this.logger.error(`Invalid OTP for user with id ${userId}`); - // throw new BadRequestException('OTP.INVALID_OTP'); - // } - - // return this.userService.verifyPhoneNumber(userId); - // } - async enableBiometric(userId: string, { deviceId, publicKey }: EnableBiometricRequestDto) { this.logger.log(`Enabling biometric for user with id ${userId}`); const device = await this.deviceService.findUserDeviceById(deviceId, userId); @@ -255,48 +135,49 @@ export class AuthService { return this.deviceService.updateDevice(deviceId, { publicKey: null }); } - async sendForgetPasswordOtp({ email }: SendForgetPasswordOtpRequestDto) { - this.logger.log(`Sending forget password OTP to ${email}`); - const user = await this.userService.findUserOrThrow({ email }); - - if (!user.isProfileCompleted) { - this.logger.error(`Profile not completed for user with email ${email}`); - throw new BadRequestException('USER.PROFILE_NOT_COMPLETED'); - } + async sendForgetPasswordOtp({ countryCode, phoneNumber }: SendForgetPasswordOtpRequestDto) { + this.logger.log(`Sending forget password OTP to ${countryCode + phoneNumber}`); + const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); return this.otpService.generateAndSendOtp({ userId: user.id, - recipient: user.email, + recipient: user.fullPhoneNumber, scope: OtpScope.FORGET_PASSWORD, - otpType: OtpType.EMAIL, + otpType: OtpType.SMS, }); } - async verifyForgetPasswordOtp({ email, otp, password, confirmPassword }: ForgetPasswordRequestDto) { - this.logger.log(`Verifying forget password OTP for ${email}`); - const user = await this.userService.findUserOrThrow({ email }); - if (!user.isProfileCompleted) { - this.logger.error(`Profile not completed for user with email ${email}`); - throw new BadRequestException('USER.PROFILE_NOT_COMPLETED'); - } + async verifyForgetPasswordOtp({ + countryCode, + phoneNumber, + otp, + password, + confirmPassword, + }: ForgetPasswordRequestDto) { + this.logger.log(`Verifying forget password OTP for ${countryCode + phoneNumber}`); + const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); + const isOtpValid = await this.otpService.verifyOtp({ userId: user.id, scope: OtpScope.FORGET_PASSWORD, - otpType: OtpType.EMAIL, + otpType: OtpType.SMS, value: otp, }); if (!isOtpValid) { - this.logger.error(`Invalid OTP for user with email ${email}`); + this.logger.error(`Invalid OTP for user with phone number ${user.fullPhoneNumber}`); throw new BadRequestException('OTP.INVALID_OTP'); } - this.validatePassword(password, confirmPassword, user); + if (password !== confirmPassword) { + this.logger.error('Password and confirm password do not match'); + throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); + } const hashedPassword = bcrypt.hashSync(password, user.salt); - await this.userService.setPasscode(user.id, hashedPassword, user.salt); - this.logger.log(`Passcode updated successfully for user with email ${email}`); + await this.userService.setPassword(user.id, hashedPassword, user.salt); + this.logger.log(`Passcode updated successfully for user with phone number ${user.fullPhoneNumber}`); } async setJuniorPasscode(body: setJuniorPasswordRequestDto) { @@ -304,7 +185,7 @@ export class AuthService { const juniorId = await this.userTokenService.validateToken(body.qrToken, UserType.JUNIOR); const salt = bcrypt.genSaltSync(SALT_ROUNDS); const hashedPasscode = bcrypt.hashSync(body.passcode, salt); - await this.userService.setPasscode(juniorId!, hashedPasscode, salt); + await this.userService.setPassword(juniorId!, hashedPasscode, salt); await this.userTokenService.invalidateToken(body.qrToken); this.logger.log(`Passcode set successfully for junior with id ${juniorId}`); } @@ -345,40 +226,6 @@ export class AuthService { } } - async sendLoginOtp({ email }: SendLoginOtpRequestDto) { - const user = await this.userService.findUserOrThrow({ email }); - - this.logger.log(`Sending login OTP to ${email}`); - return this.otpService.generateAndSendOtp({ - recipient: email, - scope: OtpScope.LOGIN, - otpType: OtpType.EMAIL, - userId: user.id, - }); - } - - async verifyLoginOtp({ email, otp }: VerifyLoginOtpRequestDto): Promise<[ILoginResponse, User]> { - const user = await this.userService.findUserOrThrow({ email }); - - this.logger.log(`Verifying login OTP for ${email}`); - const isOtpValid = await this.otpService.verifyOtp({ - otpType: OtpType.EMAIL, - scope: OtpScope.LOGIN, - userId: user.id, - value: otp, - }); - - if (!isOtpValid) { - this.logger.error(`Invalid OTP for user with email ${email}`); - throw new BadRequestException('OTP.INVALID_OTP'); - } - - this.logger.log(`Login OTP verified successfully for ${email}`); - - const token = await this.generateAuthToken(user); - return [token, user]; - } - logout(req: Request) { this.logger.log('Logging out'); const accessToken = req.headers.authorization?.split(' ')[1] as string; @@ -386,147 +233,68 @@ export class AuthService { return this.cacheService.set(accessToken, 'BLACKLISTED', expiryInTtl); } - private async loginWithPassword(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> { - const user = await this.userService.findUserOrThrow({ email: loginDto.email }); + async loginWithPassword(loginDto: LoginRequestDto): Promise<[ILoginResponse, User]> { + const user = await this.userService.findUser({ + countryCode: loginDto.countryCode, + phoneNumber: loginDto.phoneNumber, + }); - this.logger.log(`validating password for user with email ${loginDto.email}`); + if (!user) { + this.logger.error(`User not found with phone number ${loginDto.countryCode + loginDto.phoneNumber}`); + throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); + } + + if (!user.password) { + this.logger.error(`Password not set for user with phone number ${loginDto.countryCode + loginDto.phoneNumber}`); + throw new UnauthorizedException('AUTH.PHONE_NUMBER_NOT_VERIFIED'); + } + + this.logger.log(`validating password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`); const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password); if (!isPasswordValid) { - this.logger.error(`Invalid password for user with email ${loginDto.email}`); + this.logger.error(`Invalid password for user with phone ${loginDto.countryCode + loginDto.phoneNumber}`); throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS'); } const tokens = await this.generateAuthToken(user); - this.logger.log(`Password validated successfully for user with email ${loginDto.email}`); + this.logger.log(`Password validated successfully for user`); return [tokens, user]; } - private async loginWithBiometric(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> { - const user = await this.userService.findUserOrThrow({ email: loginDto.email }); + // private async loginWithBiometric(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> { + // const user = await this.userService.findUserOrThrow({ email: loginDto.email }); - this.logger.log(`validating biometric for user with email ${loginDto.email}`); - const device = await this.deviceService.findUserDeviceById(deviceId, user.id); + // this.logger.log(`validating biometric for user with email ${loginDto.email}`); + // const device = await this.deviceService.findUserDeviceById(deviceId, user.id); - if (!device) { - this.logger.error(`Device not found for user with email ${loginDto.email} and device id ${deviceId}`); - throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND'); - } + // if (!device) { + // this.logger.error(`Device not found for user with email ${loginDto.email} and device id ${deviceId}`); + // throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND'); + // } - if (!device.publicKey) { - this.logger.error(`Biometric not enabled for user with email ${loginDto.email}`); - throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED'); - } + // if (!device.publicKey) { + // this.logger.error(`Biometric not enabled for user with email ${loginDto.email}`); + // throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED'); + // } - const cleanToken = removePadding(loginDto.signature); - const isValidToken = await verifySignature( - device.publicKey, - cleanToken, - `${user.email} - ${device.deviceId}`, - 'SHA1', - ); + // const cleanToken = removePadding(loginDto.signature); + // const isValidToken = await verifySignature( + // device.publicKey, + // cleanToken, + // `${user.email} - ${device.deviceId}`, + // 'SHA1', + // ); - if (!isValidToken) { - this.logger.error(`Invalid biometric for user with email ${loginDto.email}`); - throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC'); - } + // if (!isValidToken) { + // this.logger.error(`Invalid biometric for user with email ${loginDto.email}`); + // throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC'); + // } - const tokens = await this.generateAuthToken(user); - this.logger.log(`Biometric validated successfully for user with email ${loginDto.email}`); - return [tokens, user]; - } - - async loginWithGoogle(loginDto: GoogleLoginRequestDto): Promise<[ILoginResponse, User]> { - const { - email, - sub, - given_name: firstName, - family_name: lastName, - } = await this.oauth2Service.verifyGoogleToken(loginDto.googleToken); - const [existingUser, isJunior, existingUserWithEmail] = await Promise.all([ - this.userService.findUser({ googleId: sub }), - this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }), - this.userService.findUser({ email }), - ]); - - if (isJunior) { - this.logger.error(`User with email ${email} is an already registered junior`); - throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); - } - - if (!existingUser && existingUserWithEmail) { - this.logger.error(`User with email ${email} already exists adding google id to existing user`); - await this.userService.updateUser(existingUserWithEmail.id, { googleId: sub }); - const tokens = await this.generateAuthToken(existingUserWithEmail); - return [tokens, existingUserWithEmail]; - } - - if (!existingUser && !existingUserWithEmail) { - this.logger.debug(`User with google id ${sub} or email ${email} not found, creating new user`); - const user = await this.userService.createGoogleUser(sub, email, firstName, lastName); - - const tokens = await this.generateAuthToken(user); - - return [tokens, user]; - } - - const tokens = await this.generateAuthToken(existingUser!); - - return [tokens, existingUser!]; - } - - async loginWithApple(loginDto: AppleLoginRequestDto): Promise<[ILoginResponse, User]> { - const { sub, email } = await this.oauth2Service.verifyAppleToken(loginDto.appleToken); - - const [existingUserWithSub, isJunior] = await Promise.all([ - this.userService.findUser({ appleId: sub }), - this.userService.findUser({ email, roles: ArrayContains([Roles.JUNIOR]) }), - ]); - - if (isJunior) { - this.logger.error(`User with apple id ${sub} is an already registered junior`); - throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); - } - - if (email) { - const existingUserWithEmail = await this.userService.findUser({ email }); - - if (existingUserWithEmail && !existingUserWithSub) { - { - this.logger.error(`User with email ${email} already exists adding apple id to existing user`); - await this.userService.updateUser(existingUserWithEmail.id, { appleId: sub }); - const tokens = await this.generateAuthToken(existingUserWithEmail); - return [tokens, existingUserWithEmail]; - } - } - } - - if (!existingUserWithSub) { - // Apple only provides email if user authorized zod for the first time - if (!email || !loginDto.additionalData) { - this.logger.error(`User authorized zod before but his email is not stored in the database`); - throw new BadRequestException('AUTH.APPLE_RE-CONSENT_REQUIRED'); - } - - this.logger.debug(`User with apple id ${sub} not found, creating new user`); - const user = await this.userService.createAppleUser( - sub, - email, - loginDto.additionalData.firstName, - loginDto.additionalData.lastName, - ); - - const tokens = await this.generateAuthToken(user); - - return [tokens, user]; - } - - const tokens = await this.generateAuthToken(existingUserWithSub); - - this.logger.log(`User with apple id ${sub} logged in successfully`); - - return [tokens, existingUserWithSub]; - } + // const tokens = await this.generateAuthToken(user); + // this.logger.log(`Biometric validated successfully for user with email ${loginDto.email}`); + // return [tokens, user]; + // } private async generateAuthToken(user: User) { this.logger.log(`Generating auth token for user with id ${user.id}`); @@ -550,19 +318,4 @@ export class AuthService { this.logger.log(`Auth token generated successfully for user with id ${user.id}`); return { accessToken, refreshToken, expiresAt: new Date(this.jwtService.decode(accessToken).exp * ONE_THOUSAND) }; } - - private validatePassword(password: string, confirmPassword: string, user: User) { - this.logger.log(`Validating password for user with id ${user.id}`); - if (password !== confirmPassword) { - this.logger.error(`Password mismatch for user with id ${user.id}`); - throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); - } - - if (!PASSCODE_REGEX.test(password)) { - this.logger.error(`Invalid password for user with id ${user.id}`); - throw new BadRequestException('AUTH.INVALID_PASSCODE'); - } - } - - private validateGoogleToken(googleToken: string) {} } diff --git a/src/user/services/user.service.ts b/src/user/services/user.service.ts index 3279780..2640cbe 100644 --- a/src/user/services/user.service.ts +++ b/src/user/services/user.service.ts @@ -7,12 +7,7 @@ import { Transactional } from 'typeorm-transactional'; import { CountryIso } from '~/common/enums'; import { NotificationsService } from '~/common/modules/notification/services'; import { CustomerService } from '~/customer/services'; -import { - CreateUnverifiedUserRequestDto, - CreateUnverifiedUserV2RequestDto, - VerifyUserRequestDto, - VerifyUserV2RequestDto, -} from '../../auth/dtos/request'; +import { CreateUnverifiedUserRequestDto, VerifyUserRequestDto } from '../../auth/dtos/request'; import { Roles } from '../../auth/enums'; import { CreateCheckerRequestDto, @@ -49,9 +44,9 @@ export class UserService { return this.userRepository.update(userId, { email }); } - setPasscode(userId: string, passcode: string, salt: string) { + setPassword(userId: string, password: string, salt: string) { this.logger.log(`Setting passcode for user ${userId}`); - return this.userRepository.update(userId, { password: passcode, salt, isProfileCompleted: true }); + return this.userRepository.update(userId, { password, salt }); } setPhoneNumber(userId: string, phoneNumber: string, countryCode: string) { @@ -60,23 +55,9 @@ export class UserService { } @Transactional() - async verifyUser(userId: string, body: VerifyUserRequestDto | VerifyUserV2RequestDto) { - this.logger.log(`Verifying user with id ${userId}`); - await Promise.all([ - this.customerService.createGuardianCustomer(userId, { - firstName: body.firstName, - lastName: body.lastName, - dateOfBirth: body.dateOfBirth, - countryOfResidence: body.countryOfResidence, - }), - this.userRepository.update(userId, { - isEmailVerified: true, - }), - ]); - } - - @Transactional() - async verifyUserV2(userId: string, body: VerifyUserV2RequestDto) { + async verifyUser(userId: string, body: VerifyUserRequestDto) { + const salt = bcrypt.genSaltSync(SALT_ROUNDS); + const hashedPassword = bcrypt.hashSync(body.password, salt); this.logger.log(`Verifying user with id ${userId}`); await Promise.all([ this.customerService.createGuardianCustomer(userId, { @@ -87,7 +68,8 @@ export class UserService { }), this.userRepository.update(userId, { isPhoneVerified: true, - + password: hashedPassword, + salt, ...(body.email && { email: body.email }), }), ]); @@ -113,29 +95,6 @@ export class UserService { @Transactional() async findOrCreateUser(body: CreateUnverifiedUserRequestDto) { - this.logger.log(`Finding or creating user with email ${body.email}`); - const user = await this.userRepository.findOne({ email: body.email }); - - if (!user) { - this.logger.log(`User with email ${body.email} not found, creating new user`); - return this.userRepository.createUnverifiedUser({ email: body.email, roles: [Roles.GUARDIAN] }); - } - if (user && user.roles.includes(Roles.GUARDIAN) && user.isEmailVerified) { - this.logger.error(`User with email ${body.email} already exists`); - throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN'); - } - - if (user && user.roles.includes(Roles.JUNIOR)) { - this.logger.error(`User with email ${body.email} is an already registered junior`); - throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); - //TODO add role Guardian to the existing user and send OTP - } - - this.logger.log(`User with email ${body.email}`); - return user; - } - - async findOrCreateByPhoneNumber(body: CreateUnverifiedUserV2RequestDto) { this.logger.log(`Finding or creating user with phone number ${body.countryCode + body.phoneNumber}`); const user = await this.userRepository.findOne({ phoneNumber: body.phoneNumber, @@ -167,25 +126,6 @@ export class UserService { return user; } - async findOrCreateByEmail(email: string) { - this.logger.log(`Finding or creating user with email ${email} `); - const user = await this.userRepository.findOne({ email }); - - if (!user) { - this.logger.log(`User with email ${email} not found, creating new user`); - return this.userRepository.createUser({ email, roles: [Roles.GUARDIAN] }); - } - - if (user && user.roles.includes(Roles.JUNIOR)) { - this.logger.error(`User with email ${email} is an already registered junior`); - throw new BadRequestException('USER.JUNIOR_UPGRADE_NOT_SUPPORTED_YET'); - //TODO add role Guardian to the existing user and send OTP - } - - this.logger.log(`User with email ${email} found successfully`); - return user; - } - async createUser(data: Partial) { this.logger.log(`Creating user with data ${JSON.stringify(data)}`); const user = await this.userRepository.createUser(data); From 7e63abb2fb71389864cc1a4cbdce5e948dded79f Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Thu, 31 Jul 2025 14:07:01 +0300 Subject: [PATCH 17/24] feat: add-account-details --- src/card/entities/account.entity.ts | 8 +++++ src/card/repositories/account.repository.ts | 14 ++++++-- src/card/repositories/card.repository.ts | 7 ++++ src/card/services/account.service.ts | 13 ++++++-- src/card/services/card.service.ts | 11 ++++++- src/card/services/transaction.service.ts | 2 +- .../create-application.response.dto.ts | 10 ++++++ ...count-number-and-iban-to-account-entity.ts | 32 +++++++++++++++++++ src/db/migrations/index.ts | 5 +-- 9 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 src/db/migrations/1753948642040-add-account-number-and-iban-to-account-entity.ts diff --git a/src/card/entities/account.entity.ts b/src/card/entities/account.entity.ts index cdd7847..4a1ffcb 100644 --- a/src/card/entities/account.entity.ts +++ b/src/card/entities/account.entity.ts @@ -11,6 +11,14 @@ export class Account { @Index({ unique: true }) accountReference!: string; + @Index({ unique: true }) + @Column('varchar', { length: 255, nullable: false, name: 'account_number' }) + accountNumber!: string; + + @Index({ unique: true }) + @Column('varchar', { length: 255, nullable: false, name: 'iban' }) + iban!: string; + @Column('varchar', { length: 255, nullable: false, name: 'currency' }) currency!: string; diff --git a/src/card/repositories/account.repository.ts b/src/card/repositories/account.repository.ts index da15be6..428b109 100644 --- a/src/card/repositories/account.repository.ts +++ b/src/card/repositories/account.repository.ts @@ -1,16 +1,19 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response'; import { Account } from '../entities/account.entity'; @Injectable() export class AccountRepository { constructor(@InjectRepository(Account) private readonly accountRepository: Repository) {} - createAccount(accountId: string): Promise { + createAccount(data: CreateApplicationResponse): Promise { return this.accountRepository.save( this.accountRepository.create({ - accountReference: accountId, + accountReference: data.accountId, + accountNumber: data.accountNumber, + iban: data.iBan, balance: 0, currency: '682', }), @@ -24,6 +27,13 @@ export class AccountRepository { }); } + getAccountByAccountNumber(accountNumber: string): Promise { + return this.accountRepository.findOne({ + where: { accountNumber }, + relations: ['cards'], + }); + } + topUpAccountBalance(accountReference: string, amount: number) { return this.accountRepository.increment({ accountReference }, 'balance', amount); } diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts index d747feb..d5bc71d 100644 --- a/src/card/repositories/card.repository.ts +++ b/src/card/repositories/card.repository.ts @@ -34,6 +34,13 @@ export class CardRepository { return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] }); } + getCardByAccountNumber(accountNumber: string): Promise { + return this.cardRepository.findOne({ + where: { account: { accountNumber } }, + relations: ['account'], + }); + } + getActiveCardForCustomer(customerId: string): Promise { return this.cardRepository.findOne({ where: { customerId, status: CardStatus.ACTIVE }, diff --git a/src/card/services/account.service.ts b/src/card/services/account.service.ts index cce8e68..ac9e0dd 100644 --- a/src/card/services/account.service.ts +++ b/src/card/services/account.service.ts @@ -1,4 +1,5 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { CreateApplicationResponse } from '~/common/modules/neoleap/dtos/response'; import { Account } from '../entities/account.entity'; import { AccountRepository } from '../repositories/account.repository'; @@ -6,8 +7,8 @@ import { AccountRepository } from '../repositories/account.repository'; export class AccountService { constructor(private readonly accountRepository: AccountRepository) {} - createAccount(accountId: string): Promise { - return this.accountRepository.createAccount(accountId); + createAccount(data: CreateApplicationResponse): Promise { + return this.accountRepository.createAccount(data); } async getAccountByReferenceNumber(accountReference: string): Promise { @@ -18,6 +19,14 @@ export class AccountService { return account; } + async getAccountByAccountNumber(accountNumber: string): Promise { + const account = await this.accountRepository.getAccountByAccountNumber(accountNumber); + if (!account) { + throw new UnprocessableEntityException('ACCOUNT.NOT_FOUND'); + } + return account; + } + async creditAccountBalance(accountReference: string, amount: number) { return this.accountRepository.topUpAccountBalance(accountReference, amount); } diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index 61f785e..0241852 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -13,7 +13,7 @@ export class CardService { @Transactional() async createCard(customerId: string, cardData: CreateApplicationResponse): Promise { - const account = await this.accountService.createAccount(cardData.accountId); + const account = await this.accountService.createAccount(cardData); return this.cardRepository.createCard(customerId, account.id, cardData); } @@ -37,6 +37,15 @@ export class CardService { return card; } + async getCardByAccountNumber(accountNumber: string): Promise { + const card = await this.cardRepository.getCardByAccountNumber(accountNumber); + + if (!card) { + throw new BadRequestException('CARD.NOT_FOUND'); + } + return card; + } + async getActiveCardForCustomer(customerId: string): Promise { const card = await this.cardRepository.getActiveCardForCustomer(customerId); if (!card) { diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index d512299..4db3a80 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -20,7 +20,7 @@ export class TransactionService { @Transactional() async createCardTransaction(body: CardTransactionWebhookRequest) { - const card = await this.cardService.getCardByReferenceNumber(body.cardId); + const card = await this.cardService.getCardByAccountNumber(body.cardId); const existingTransaction = await this.findExistingTransaction(body.transactionId, card.account.accountReference); if (existingTransaction) { diff --git a/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts index f135b4f..8da0c0a 100644 --- a/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts +++ b/src/common/modules/neoleap/dtos/response/create-application.response.dto.ts @@ -37,4 +37,14 @@ export class CreateApplicationResponse extends InquireApplicationResponse { @Expose() @ApiProperty() accountId!: string; + + @Transform(({ obj }) => obj.AccountDetailsList?.[0]?.AccountNumber) + @Expose() + @ApiProperty() + accountNumber!: string; + + @Transform(({ obj }) => obj.AccountDetailsList?.[0]?.UserData5) + @Expose() + @ApiProperty() + iBan!: string; } diff --git a/src/db/migrations/1753948642040-add-account-number-and-iban-to-account-entity.ts b/src/db/migrations/1753948642040-add-account-number-and-iban-to-account-entity.ts new file mode 100644 index 0000000..165be69 --- /dev/null +++ b/src/db/migrations/1753948642040-add-account-number-and-iban-to-account-entity.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +export class AddAccountNumberAndIbanToAccountEntity1753948642040 implements MigrationInterface { + name = 'AddAccountNumberAndIbanToAccountEntity1753948642040'; + + public async up(queryRunner: QueryRunner): Promise { + // Step 1: Add columns as nullable + await queryRunner.query(`ALTER TABLE "accounts" ADD "account_number" character varying(255)`); + await queryRunner.query(`ALTER TABLE "accounts" ADD "iban" character varying(255)`); + + // Step 2: Populate dummy values or correct ones + await queryRunner.query(` + UPDATE "accounts" + SET "account_number" = 'TEMP_ACC_' || id, + "iban" = 'TEMP_IBAN_' || id + `); + + // Step 3: Alter columns to be NOT NULL + await queryRunner.query(`ALTER TABLE "accounts" ALTER COLUMN "account_number" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "accounts" ALTER COLUMN "iban" SET NOT NULL`); + + // Step 4: Add unique indexes + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_ffd1ae96513bfb2c6eada0f7d3" ON "accounts" ("account_number")`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_9a4b004902294416b096e7556e" ON "accounts" ("iban")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_9a4b004902294416b096e7556e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ffd1ae96513bfb2c6eada0f7d3"`); + await queryRunner.query(`ALTER TABLE "accounts" DROP COLUMN "iban"`); + await queryRunner.query(`ALTER TABLE "accounts" DROP COLUMN "account_number"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 9996c42..8f22bfe 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -1,5 +1,6 @@ -export * from './1753869637732-seed-default-avatar'; +export * from './1733750228289-initial-migration'; export * from './1733990253208-seeds-default-tasks-logo'; export * from './1734247702310-seeds-goals-categories'; -export * from './1733750228289-initial-migration'; +export * from './1753869637732-seed-default-avatar'; export * from './1753874205042-add-neoleap-related-entities'; +export * from './1753948642040-add-account-number-and-iban-to-account-entity'; From f9776e60cfd7c9667d9e97a0a50c1807330164ca Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Thu, 31 Jul 2025 14:11:53 +0300 Subject: [PATCH 18/24] fix: save transaction file --- src/card/services/transaction.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index 4db3a80..17973a3 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -37,7 +37,7 @@ export class TransactionService { @Transactional() async createAccountTransaction(body: AccountTransactionWebhookRequest) { - const account = await this.accountService.getAccountByReferenceNumber(body.accountId); + const account = await this.accountService.getAccountByAccountNumber(body.accountId); const existingTransaction = await this.findExistingTransaction(body.transactionId, account.accountReference); From 5e0a4e6bd1f60be5a75a3f441df1fd9b256aeb0d Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Thu, 31 Jul 2025 14:42:06 +0300 Subject: [PATCH 19/24] feat: fix update card status webhook --- src/card/services/card.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index 0241852..e2e862b 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -55,7 +55,7 @@ export class CardService { } async updateCardStatus(body: AccountCardStatusChangedWebhookRequest) { - const card = await this.getCardByReferenceNumber(body.cardId); + const card = await this.getCardByAccountNumber(body.cardId); const { description, status } = CardStatusMapper[body.newStatus] || CardStatusMapper['99']; return this.cardRepository.updateCardStatus(card.id, status, description); From fce720237f5df89577a7a0f0ca63761b710cf3dc Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 3 Aug 2025 11:53:16 +0300 Subject: [PATCH 20/24] feat: add vpan to card entity --- src/card/entities/card.entity.ts | 4 ++++ src/card/repositories/card.repository.ts | 5 +++-- src/card/services/card.service.ts | 6 ++--- src/card/services/transaction.service.ts | 2 +- .../neoleap-webhooks.controller.ts | 10 ++++++--- .../1754210729273-add-vpan-to-card.ts | 22 +++++++++++++++++++ src/db/migrations/index.ts | 1 + 7 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 src/db/migrations/1754210729273-add-vpan-to-card.ts diff --git a/src/card/entities/card.entity.ts b/src/card/entities/card.entity.ts index 53ac864..72ce7a0 100644 --- a/src/card/entities/card.entity.ts +++ b/src/card/entities/card.entity.ts @@ -23,6 +23,10 @@ export class Card { @Column({ name: 'card_reference', nullable: false, type: 'varchar' }) cardReference!: string; + @Index({ unique: true }) + @Column({ name: 'vpan', nullable: false, type: 'varchar' }) + vpan!: string; + @Column({ length: 6, name: 'first_six_digits', nullable: false, type: 'varchar' }) firstSixDigits!: string; diff --git a/src/card/repositories/card.repository.ts b/src/card/repositories/card.repository.ts index d5bc71d..a6e8cb6 100644 --- a/src/card/repositories/card.repository.ts +++ b/src/card/repositories/card.repository.ts @@ -22,6 +22,7 @@ export class CardRepository { scheme: CardScheme.VISA, issuer: CardIssuers.NEOLEAP, accountId: accountId, + vpan: card.vpan, }), ); } @@ -34,9 +35,9 @@ export class CardRepository { return this.cardRepository.findOne({ where: { cardReference: referenceNumber }, relations: ['account'] }); } - getCardByAccountNumber(accountNumber: string): Promise { + getCardByVpan(vpan: string): Promise { return this.cardRepository.findOne({ - where: { account: { accountNumber } }, + where: { vpan }, relations: ['account'], }); } diff --git a/src/card/services/card.service.ts b/src/card/services/card.service.ts index e2e862b..6d48d0f 100644 --- a/src/card/services/card.service.ts +++ b/src/card/services/card.service.ts @@ -37,8 +37,8 @@ export class CardService { return card; } - async getCardByAccountNumber(accountNumber: string): Promise { - const card = await this.cardRepository.getCardByAccountNumber(accountNumber); + async getCardByVpan(vpan: string): Promise { + const card = await this.cardRepository.getCardByVpan(vpan); if (!card) { throw new BadRequestException('CARD.NOT_FOUND'); @@ -55,7 +55,7 @@ export class CardService { } async updateCardStatus(body: AccountCardStatusChangedWebhookRequest) { - const card = await this.getCardByAccountNumber(body.cardId); + const card = await this.getCardByVpan(body.cardId); const { description, status } = CardStatusMapper[body.newStatus] || CardStatusMapper['99']; return this.cardRepository.updateCardStatus(card.id, status, description); diff --git a/src/card/services/transaction.service.ts b/src/card/services/transaction.service.ts index 17973a3..ff22aef 100644 --- a/src/card/services/transaction.service.ts +++ b/src/card/services/transaction.service.ts @@ -20,7 +20,7 @@ export class TransactionService { @Transactional() async createCardTransaction(body: CardTransactionWebhookRequest) { - const card = await this.cardService.getCardByAccountNumber(body.cardId); + const card = await this.cardService.getCardByVpan(body.cardId); const existingTransaction = await this.findExistingTransaction(body.transactionId, card.account.accountReference); if (existingTransaction) { diff --git a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts index 1fb8d60..03aaf8a 100644 --- a/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts +++ b/src/common/modules/neoleap/controllers/neoleap-webhooks.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { ResponseFactory } from '~/core/utils'; import { AccountCardStatusChangedWebhookRequest, AccountTransactionWebhookRequest, @@ -14,16 +15,19 @@ export class NeoLeapWebhooksController { @Post('card-transaction') async handleCardTransactionWebhook(@Body() body: CardTransactionWebhookRequest) { - return this.neoleapWebhookService.handleCardTransactionWebhook(body); + await this.neoleapWebhookService.handleCardTransactionWebhook(body); + return ResponseFactory.data({ message: 'Card transaction processed successfully', status: 'success' }); } @Post('account-transaction') async handleAccountTransactionWebhook(@Body() body: AccountTransactionWebhookRequest) { - return this.neoleapWebhookService.handleAccountTransactionWebhook(body); + await this.neoleapWebhookService.handleAccountTransactionWebhook(body); + return ResponseFactory.data({ message: 'Account transaction processed successfully', status: 'success' }); } @Post('account-card-status-changed') async handleAccountCardStatusChangedWebhook(@Body() body: AccountCardStatusChangedWebhookRequest) { - return this.neoleapWebhookService.handleAccountCardStatusChangedWebhook(body); + await this.neoleapWebhookService.handleAccountCardStatusChangedWebhook(body); + return ResponseFactory.data({ message: 'Card status updated successfully', status: 'success' }); } } diff --git a/src/db/migrations/1754210729273-add-vpan-to-card.ts b/src/db/migrations/1754210729273-add-vpan-to-card.ts new file mode 100644 index 0000000..03d7beb --- /dev/null +++ b/src/db/migrations/1754210729273-add-vpan-to-card.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddVpanToCard1754210729273 implements MigrationInterface { + name = 'AddVpanToCard1754210729273'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "cards" ADD "vpan" character varying`); + await queryRunner.query(` + UPDATE "cards" + SET "vpan" = 'TEMP_VPAN_' || id + `); + + await queryRunner.query(`ALTER TABLE "cards" ALTER COLUMN "vpan" SET NOT NULL`); + + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_1ec2ef68b0370f26639261e87b" ON "cards" ("vpan") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_1ec2ef68b0370f26639261e87b"`); + await queryRunner.query(`ALTER TABLE "cards" DROP COLUMN "vpan"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 8f22bfe..63f3865 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -4,3 +4,4 @@ export * from './1734247702310-seeds-goals-categories'; export * from './1753869637732-seed-default-avatar'; export * from './1753874205042-add-neoleap-related-entities'; export * from './1753948642040-add-account-number-and-iban-to-account-entity'; +export * from './1754210729273-add-vpan-to-card'; From f65a7d293383affecbce47ddd28e4fa95002465b Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 3 Aug 2025 14:21:14 +0300 Subject: [PATCH 21/24] feat: generate upload signed url for oci --- .../controllers/document.controller.ts | 45 +++---------------- .../generate-upload-signed-url.request.dto.ts | 18 ++++++++ src/document/dtos/request/index.ts | 1 + src/document/services/document.service.ts | 9 +++- src/document/services/oci.service.ts | 45 ++++++++++++++++++- 5 files changed, 78 insertions(+), 40 deletions(-) create mode 100644 src/document/dtos/request/generate-upload-signed-url.request.dto.ts diff --git a/src/document/controllers/document.controller.ts b/src/document/controllers/document.controller.ts index 7b3eed1..2fc2a72 100644 --- a/src/document/controllers/document.controller.ts +++ b/src/document/controllers/document.controller.ts @@ -1,15 +1,9 @@ -import { Body, Controller, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; -import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; -import { memoryStorage } from 'multer'; -import { IJwtPayload } from '~/auth/interfaces'; -import { AuthenticatedUser } from '~/common/decorators'; +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { AccessTokenGuard } from '~/common/guards'; import { ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; -import { UploadDocumentRequestDto } from '../dtos/request'; -import { DocumentMetaResponseDto } from '../dtos/response'; -import { DocumentType } from '../enums'; +import { GenerateUploadSignedUrlRequestDto } from '../dtos/request'; import { DocumentService } from '../services'; @Controller('document') @ApiTags('Document') @@ -19,34 +13,9 @@ import { DocumentService } from '../services'; export class DocumentController { constructor(private readonly documentService: DocumentService) {} - @Post() - @ApiConsumes('multipart/form-data') - @ApiBody({ - schema: { - type: 'object', - properties: { - document: { - type: 'string', - format: 'binary', - }, - documentType: { - type: 'string', - enum: Object.values(DocumentType).filter( - (value) => ![DocumentType.DEFAULT_AVATAR, DocumentType.DEFAULT_TASKS_LOGO].includes(value), - ), - }, - }, - required: ['document', 'documentType'], - }, - }) - @UseInterceptors(FileInterceptor('document', { storage: memoryStorage() })) - async createDocument( - @UploadedFile() file: Express.Multer.File, - @Body() uploadedDocumentRequest: UploadDocumentRequestDto, - @AuthenticatedUser() user: IJwtPayload, - ) { - const document = await this.documentService.createDocument(file, uploadedDocumentRequest, user.sub); - - return ResponseFactory.data(new DocumentMetaResponseDto(document)); + @Post('signed-url') + async generateSignedUrl(@Body() body: GenerateUploadSignedUrlRequestDto) { + const signedUrl = await this.documentService.generateUploadSignedUrl(body); + return ResponseFactory.data(signedUrl); } } diff --git a/src/document/dtos/request/generate-upload-signed-url.request.dto.ts b/src/document/dtos/request/generate-upload-signed-url.request.dto.ts new file mode 100644 index 0000000..38e83b2 --- /dev/null +++ b/src/document/dtos/request/generate-upload-signed-url.request.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsString } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { DocumentType } from '~/document/enums'; + +export class GenerateUploadSignedUrlRequestDto { + @ApiProperty({ enum: DocumentType }) + @IsEnum(DocumentType, { message: i18n('validation.IsEnum', { path: 'general', property: 'document.documentType' }) }) + documentType!: DocumentType; + + @ApiProperty({ type: String }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'document.document.extension' }) }) + extension!: string; + + @ApiProperty({ type: String }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'document.originalFileName' }) }) + originalFileName!: string; +} diff --git a/src/document/dtos/request/index.ts b/src/document/dtos/request/index.ts index 4071ca7..2dd4fad 100644 --- a/src/document/dtos/request/index.ts +++ b/src/document/dtos/request/index.ts @@ -1 +1,2 @@ +export * from './generate-upload-signed-url.request.dto'; export * from './upload-document.request.dto'; diff --git a/src/document/services/document.service.ts b/src/document/services/document.service.ts index 5378942..bdc72cd 100644 --- a/src/document/services/document.service.ts +++ b/src/document/services/document.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; -import { UploadDocumentRequestDto } from '../dtos/request'; +import { GenerateUploadSignedUrlRequestDto, UploadDocumentRequestDto } from '../dtos/request'; import { Document } from '../entities'; import { DocumentRepository } from '../repositories'; import { OciService } from './oci.service'; @@ -24,4 +24,11 @@ export class DocumentService { this.logger.log(`finding document with id ${id}`); return this.documentRepository.findDocumentById(id); } + + generateUploadSignedUrl(body: GenerateUploadSignedUrlRequestDto) { + this.logger.log( + `generating signed URL for document type ${body.documentType} with original file name ${body.originalFileName}`, + ); + return this.ociService.generateUploadPreSignedUrl(body); + } } diff --git a/src/document/services/oci.service.ts b/src/document/services/oci.service.ts index 3e65a89..9c6002d 100644 --- a/src/document/services/oci.service.ts +++ b/src/document/services/oci.service.ts @@ -8,7 +8,7 @@ import { CreatePreauthenticatedRequestDetails } from 'oci-objectstorage/lib/mode import path from 'path'; import { CacheService } from '~/common/modules/cache/services'; import { BUCKETS } from '../constants'; -import { UploadDocumentRequestDto } from '../dtos/request'; +import { GenerateUploadSignedUrlRequestDto, UploadDocumentRequestDto } from '../dtos/request'; import { UploadResponseDto } from '../dtos/response'; import { Document } from '../entities'; import { generateNewFileName } from '../utils'; @@ -106,4 +106,47 @@ export class OciService { return document.name; } } + + async generateUploadPreSignedUrl({ + documentType, + originalFileName, + extension, + }: GenerateUploadSignedUrlRequestDto): Promise { + const bucketName = BUCKETS[documentType]; + + if (!bucketName) { + this.logger.error('Could not find bucket name for document type', documentType); + throw new BadRequestException('DOCUMENT.TYPE_NOT_SUPPORTED'); + } + + const objectName = generateNewFileName(originalFileName); + const expiration = moment().add('1', 'hours').toDate(); + + try { + this.logger.debug(`Generating pre-signed upload URL for object ${objectName} in bucket ${bucketName}`); + + const response = await this.ociClient.createPreauthenticatedRequest({ + namespaceName: this.namespace, + bucketName, + createPreauthenticatedRequestDetails: { + name: `upload-${objectName}`, + accessType: CreatePreauthenticatedRequestDetails.AccessType.ObjectWrite, // 🔑 for upload + timeExpires: expiration, + objectName: `${objectName}${extension}`, // Ensure the object name includes the extension + }, + }); + + this.logger.log(`Generated upload URL for ${objectName}`); + + return plainToInstance(UploadResponseDto, { + name: objectName, + extension, + url: response.preauthenticatedRequest.fullPath, + documentType, + }); + } catch (error) { + this.logger.error('Error generating pre-signed upload URL', error); + throw new BadRequestException('UPLOAD.URL_GENERATION_FAILED'); + } + } } From 7461af20dd70b5428cb544760155499532fba824 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 3 Aug 2025 14:48:14 +0300 Subject: [PATCH 22/24] feat: edit forget password flow --- src/auth/controllers/auth.controller.ts | 12 +++++- .../request/forget-password.request.dto.ts | 20 +++------- src/auth/dtos/request/index.ts | 1 + .../verify-forget-password-otp.request.dto.ts | 23 +++++++++++ ...verify-forget-password-otp.response.dto.ts | 19 ++++++++++ src/auth/services/auth.service.ts | 38 ++++++++++++++----- src/i18n/ar/app.json | 4 +- src/i18n/en/app.json | 4 +- src/user/enums/user-type.enum.ts | 1 + .../repositories/user-token-repository.ts | 2 +- src/user/services/user-token.service.ts | 4 +- 11 files changed, 97 insertions(+), 31 deletions(-) create mode 100644 src/auth/dtos/request/verify-forget-password-otp.request.dto.ts create mode 100644 src/auth/dtos/response/verify-forget-password-otp.response.dto.ts diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index f651e69..e3bafcf 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -11,10 +11,12 @@ import { LoginRequestDto, RefreshTokenRequestDto, SendForgetPasswordOtpRequestDto, + VerifyForgetPasswordOtpRequestDto, VerifyUserRequestDto, } from '../dtos/request'; import { SendForgetPasswordOtpResponseDto, SendRegisterOtpResponseDto } from '../dtos/response'; import { LoginResponseDto } from '../dtos/response/login.response.dto'; +import { VerifyForgetPasswordOtpResponseDto } from '../dtos/response/verify-forget-password-otp.response.dto'; import { AuthService } from '../services'; @Controller('auth') @@ -47,10 +49,18 @@ export class AuthController { return ResponseFactory.data(new SendForgetPasswordOtpResponseDto(maskedNumber)); } + @Post('forget-password/verify') + @HttpCode(HttpStatus.OK) + async verifyForgetPasswordOtp(@Body() forgetPasswordDto: VerifyForgetPasswordOtpRequestDto) { + const { token, user } = await this.authService.verifyForgetPasswordOtp(forgetPasswordDto); + + return ResponseFactory.data(new VerifyForgetPasswordOtpResponseDto(token, user)); + } + @Post('forget-password/reset') @HttpCode(HttpStatus.NO_CONTENT) resetPassword(@Body() forgetPasswordDto: ForgetPasswordRequestDto) { - return this.authService.verifyForgetPasswordOtp(forgetPasswordDto); + return this.authService.resetPassword(forgetPasswordDto); } @Post('refresh-token') diff --git a/src/auth/dtos/request/forget-password.request.dto.ts b/src/auth/dtos/request/forget-password.request.dto.ts index 4153a96..4680c71 100644 --- a/src/auth/dtos/request/forget-password.request.dto.ts +++ b/src/auth/dtos/request/forget-password.request.dto.ts @@ -1,8 +1,7 @@ -import { ApiProperty, PickType } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, IsNumberString, IsString, Matches, MaxLength, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, Matches } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { COUNTRY_CODE_REGEX, PASSWORD_REGEX } from '~/auth/constants'; -import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; import { IsValidPhoneNumber } from '~/core/decorators/validations'; export class ForgetPasswordRequestDto { @ApiProperty({ example: '+962' }) @@ -29,16 +28,7 @@ export class ForgetPasswordRequestDto { }) confirmPassword!: string; - @ApiProperty({ example: '111111' }) - @IsNumberString( - { no_symbols: true }, - { message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) }, - ) - @MaxLength(DEFAULT_OTP_LENGTH, { - message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), - }) - @MinLength(DEFAULT_OTP_LENGTH, { - message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), - }) - otp!: string; + @ApiProperty({ example: 'reset-token-32423123' }) + @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.resetPasswordToken' }) }) + resetPasswordToken!: string; } diff --git a/src/auth/dtos/request/index.ts b/src/auth/dtos/request/index.ts index a329e46..09274d4 100644 --- a/src/auth/dtos/request/index.ts +++ b/src/auth/dtos/request/index.ts @@ -10,5 +10,6 @@ export * from './send-forget-password-otp.request.dto'; export * from './set-email.request.dto'; export * from './set-junior-password.request.dto'; export * from './set-passcode.request.dto'; +export * from './verify-forget-password-otp.request.dto'; export * from './verify-otp.request.dto'; export * from './verify-user.request.dto'; diff --git a/src/auth/dtos/request/verify-forget-password-otp.request.dto.ts b/src/auth/dtos/request/verify-forget-password-otp.request.dto.ts new file mode 100644 index 0000000..03f2100 --- /dev/null +++ b/src/auth/dtos/request/verify-forget-password-otp.request.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, PickType } from '@nestjs/swagger'; +import { IsNumberString, MaxLength, MinLength } from 'class-validator'; +import { i18nValidationMessage as i18n } from 'nestjs-i18n'; +import { DEFAULT_OTP_LENGTH } from '~/common/modules/otp/constants'; +import { ForgetPasswordRequestDto } from './forget-password.request.dto'; + +export class VerifyForgetPasswordOtpRequestDto extends PickType(ForgetPasswordRequestDto, [ + 'countryCode', + 'phoneNumber', +]) { + @ApiProperty({ example: '111111' }) + @IsNumberString( + { no_symbols: true }, + { message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.otp' }) }, + ) + @MaxLength(DEFAULT_OTP_LENGTH, { + message: i18n('validation.MaxLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), + }) + @MinLength(DEFAULT_OTP_LENGTH, { + message: i18n('validation.MinLength', { path: 'general', property: 'auth.otp', length: DEFAULT_OTP_LENGTH }), + }) + otp!: string; +} diff --git a/src/auth/dtos/response/verify-forget-password-otp.response.dto.ts b/src/auth/dtos/response/verify-forget-password-otp.response.dto.ts new file mode 100644 index 0000000..8fc6010 --- /dev/null +++ b/src/auth/dtos/response/verify-forget-password-otp.response.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { User } from '~/user/entities'; + +export class VerifyForgetPasswordOtpResponseDto { + @ApiProperty() + phoneNumber!: string; + + @ApiProperty() + countryCode!: string; + + @ApiProperty() + resetPasswordToken!: string; + + constructor(token: string, user: User) { + this.phoneNumber = user.phoneNumber; + this.countryCode = user.countryCode; + this.resetPasswordToken = token; + } +} diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index ca5e4dd..e096196 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -3,13 +3,13 @@ import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { Request } from 'express'; +import moment from 'moment'; import { CacheService } from '~/common/modules/cache/services'; import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpService } from '~/common/modules/otp/services'; import { UserType } from '~/user/enums'; import { DeviceService, UserService, UserTokenService } from '~/user/services'; import { User } from '../../user/entities'; -import { PASSCODE_REGEX } from '../constants'; import { CreateUnverifiedUserRequestDto, DisableBiometricRequestDto, @@ -18,10 +18,11 @@ import { LoginRequestDto, SendForgetPasswordOtpRequestDto, setJuniorPasswordRequestDto, + VerifyForgetPasswordOtpRequestDto, VerifyUserRequestDto, } from '../dtos/request'; +import { Roles } from '../enums'; import { IJwtPayload, ILoginResponse } from '../interfaces'; -import { removePadding, verifySignature } from '../utils'; import { Oauth2Service } from './oauth2.service'; const ONE_THOUSAND = 1000; @@ -147,14 +148,7 @@ export class AuthService { }); } - async verifyForgetPasswordOtp({ - countryCode, - phoneNumber, - otp, - password, - confirmPassword, - }: ForgetPasswordRequestDto) { - this.logger.log(`Verifying forget password OTP for ${countryCode + phoneNumber}`); + async verifyForgetPasswordOtp({ countryCode, phoneNumber, otp }: VerifyForgetPasswordOtpRequestDto) { const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); const isOtpValid = await this.otpService.verifyOtp({ @@ -169,6 +163,29 @@ export class AuthService { throw new BadRequestException('OTP.INVALID_OTP'); } + // generate a token for the user to reset password + const token = await this.userTokenService.generateToken( + user.id, + user.roles.includes(Roles.GUARDIAN) ? UserType.GUARDIAN : UserType.JUNIOR, + moment().add(5, 'minutes').toDate(), + ); + + return { token, user }; + } + async resetPassword({ + countryCode, + phoneNumber, + resetPasswordToken, + password, + confirmPassword, + }: ForgetPasswordRequestDto) { + this.logger.log(`Verifying forget password OTP for ${countryCode + phoneNumber}`); + const user = await this.userService.findUserOrThrow({ countryCode, phoneNumber }); + await this.userTokenService.validateToken( + resetPasswordToken, + user.roles.includes(Roles.GUARDIAN) ? UserType.GUARDIAN : UserType.JUNIOR, + ); + if (password !== confirmPassword) { this.logger.error('Password and confirm password do not match'); throw new BadRequestException('AUTH.PASSWORD_MISMATCH'); @@ -177,6 +194,7 @@ export class AuthService { const hashedPassword = bcrypt.hashSync(password, user.salt); await this.userService.setPassword(user.id, hashedPassword, user.salt); + await this.userTokenService.invalidateToken(resetPasswordToken); this.logger.log(`Passcode updated successfully for user with phone number ${user.fullPhoneNumber}`); } diff --git a/src/i18n/ar/app.json b/src/i18n/ar/app.json index 455ddaf..797784d 100644 --- a/src/i18n/ar/app.json +++ b/src/i18n/ar/app.json @@ -11,7 +11,9 @@ "PASSWORD_MISMATCH": "كلمات المرور التي أدخلتها غير متطابقة. يرجى إدخال كلمات المرور مرة أخرى.", "INVALID_PASSCODE": "رمز المرور الذي أدخلته غير صحيح. يرجى المحاولة مرة أخرى.", "PASSCODE_ALREADY_SET": "تم تعيين رمز المرور بالفعل.", - "APPLE_RE-CONSENT_REQUIRED": "إعادة الموافقة على آبل مطلوبة. يرجى إلغاء تطبيقك من إعدادات معرف آبل الخاصة بك والمحاولة مرة أخرى." + "APPLE_RE-CONSENT_REQUIRED": "إعادة الموافقة على آبل مطلوبة. يرجى إلغاء تطبيقك من إعدادات معرف آبل الخاصة بك والمحاولة مرة أخرى.", + "TOKEN_INVALID": "رمز المستخدم غير صالح.", + "TOKEN_EXPIRED": "رمز المستخدم منتهي الصلاحية." }, "USER": { diff --git a/src/i18n/en/app.json b/src/i18n/en/app.json index 105afc9..668b1e8 100644 --- a/src/i18n/en/app.json +++ b/src/i18n/en/app.json @@ -11,7 +11,9 @@ "PASSWORD_MISMATCH": "The passwords you entered do not match. Please re-enter the passwords.", "INVALID_PASSCODE": "The passcode you entered is incorrect. Please try again.", "PASSCODE_ALREADY_SET": "The pass code has already been set.", - "APPLE_RE-CONSENT_REQUIRED": "Apple re-consent is required. Please revoke the app from your Apple ID settings and try again." + "APPLE_RE-CONSENT_REQUIRED": "Apple re-consent is required. Please revoke the app from your Apple ID settings and try again.", + "TOKEN_INVALID": "The user token is invalid.", + "TOKEN_EXPIRED": "The user token has expired." }, "USER": { diff --git a/src/user/enums/user-type.enum.ts b/src/user/enums/user-type.enum.ts index e00c99c..b3a705f 100644 --- a/src/user/enums/user-type.enum.ts +++ b/src/user/enums/user-type.enum.ts @@ -1,4 +1,5 @@ export enum UserType { CHECKER = 'CHECKER', JUNIOR = 'JUNIOR', + GUARDIAN = 'GUARDIAN', } diff --git a/src/user/repositories/user-token-repository.ts b/src/user/repositories/user-token-repository.ts index 3e130db..9eda1bf 100644 --- a/src/user/repositories/user-token-repository.ts +++ b/src/user/repositories/user-token-repository.ts @@ -17,7 +17,7 @@ export class UserTokenRepository { generateToken(userId: string, userType: UserType, expiryDate?: Date) { return this.userTokenRepository.save( this.userTokenRepository.create({ - userId: userType === UserType.CHECKER ? userId : null, + userId, juniorId: userType === UserType.JUNIOR ? userId : null, expiryDate: expiryDate ?? moment().add('15', 'minutes').toDate(), userType, diff --git a/src/user/services/user-token.service.ts b/src/user/services/user-token.service.ts index 3a3ee1b..efcbe00 100644 --- a/src/user/services/user-token.service.ts +++ b/src/user/services/user-token.service.ts @@ -20,12 +20,12 @@ export class UserTokenService { if (!tokenEntity) { this.logger.error(`Token ${token} not found`); - throw new BadRequestException('TOKEN.INVALID'); + throw new BadRequestException('AUTH.TOKEN_INVALID'); } if (tokenEntity.expiryDate < new Date()) { this.logger.error(`Token ${token} expired`); - throw new BadRequestException('TOKEN.EXPIRED'); + throw new BadRequestException('AUTH.TOKEN_EXPIRED'); } this.logger.log(`Token validated successfully`); From 4cc52a1c07b63d6a2d25d238491b3faa88f4e13b Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 3 Aug 2025 14:50:04 +0300 Subject: [PATCH 23/24] fix: add swagger doc to verify otp api --- src/auth/controllers/auth.controller.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index e3bafcf..61a1985 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -3,7 +3,7 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; import { Public } from '~/common/decorators'; import { AccessTokenGuard } from '~/common/guards'; -import { ApiLangRequestHeader } from '~/core/decorators'; +import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; import { CreateUnverifiedUserRequestDto, @@ -51,6 +51,7 @@ export class AuthController { @Post('forget-password/verify') @HttpCode(HttpStatus.OK) + @ApiDataResponse(VerifyForgetPasswordOtpResponseDto) async verifyForgetPasswordOtp(@Body() forgetPasswordDto: VerifyForgetPasswordOtpRequestDto) { const { token, user } = await this.authService.verifyForgetPasswordOtp(forgetPasswordDto); From 1e2b859b920196947c0ce99f20d8c57f96542882 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Sun, 3 Aug 2025 16:18:06 +0300 Subject: [PATCH 24/24] feat: finish generating signed url for document upload flow --- .../1733990253208-seeds-default-tasks-logo.ts | 6 +++- .../1753869637732-seed-default-avatar.ts | 11 +++++- ...47-add-upload-status-to-document-entity.ts | 15 ++++++++ src/db/migrations/index.ts | 1 + .../controllers/document.controller.ts | 25 ++++++++++---- ....dto.ts => create-document.request.dto.ts} | 4 +-- src/document/dtos/request/index.ts | 3 +- .../request/upload-document.request.dto.ts | 8 ----- ...generate-upload-signed-url.response.dto.ts | 16 +++++++++ src/document/dtos/response/index.ts | 1 + src/document/entities/document.entity.ts | 5 ++- src/document/enums/index.ts | 1 + src/document/enums/upload-status.enum.ts | 4 +++ .../repositories/document.repository.ts | 4 +++ src/document/services/document.service.ts | 29 +++++++++++----- src/document/services/oci.service.ts | 34 ++----------------- 16 files changed, 105 insertions(+), 62 deletions(-) create mode 100644 src/db/migrations/1754226754947-add-upload-status-to-document-entity.ts rename src/document/dtos/request/{generate-upload-signed-url.request.dto.ts => create-document.request.dto.ts} (88%) delete mode 100644 src/document/dtos/request/upload-document.request.dto.ts create mode 100644 src/document/dtos/response/generate-upload-signed-url.response.dto.ts create mode 100644 src/document/enums/upload-status.enum.ts diff --git a/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts b/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts index ea02a1c..94661aa 100644 --- a/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts +++ b/src/db/migrations/1733990253208-seeds-default-tasks-logo.ts @@ -1,31 +1,35 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import { v4 as uuid } from 'uuid'; import { Document } from '~/document/entities'; -import { DocumentType } from '~/document/enums'; +import { DocumentType, UploadStatus } from '~/document/enums'; const DEFAULT_TASK_LOGOS = [ { id: uuid(), name: 'bed-furniture', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'dog', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'dish-washing', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'walking-the-dog', extension: '.jpg', documentType: DocumentType.DEFAULT_TASKS_LOGO, + uploadStatus: UploadStatus.UPLOADED, }, ]; export class SeedsDefaultTasksLogo1733990253208 implements MigrationInterface { diff --git a/src/db/migrations/1753869637732-seed-default-avatar.ts b/src/db/migrations/1753869637732-seed-default-avatar.ts index 7b1a5e3..a6c5935 100644 --- a/src/db/migrations/1753869637732-seed-default-avatar.ts +++ b/src/db/migrations/1753869637732-seed-default-avatar.ts @@ -1,61 +1,70 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; import { v4 as uuid } from 'uuid'; import { Document } from '../../document/entities'; -import { DocumentType } from '../../document/enums'; +import { DocumentType, UploadStatus } from '../../document/enums'; const DEFAULT_AVATARS = [ { id: uuid(), name: 'vacation', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'colors', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'astronaut', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'pet', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'disney', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'clothes', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'playstation', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'football', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, { id: uuid(), name: 'cars', extension: '.jpg', documentType: DocumentType.DEFAULT_AVATAR, + uploadStatus: UploadStatus.UPLOADED, }, ]; export class SeedDefaultAvatar1753869637732 implements MigrationInterface { diff --git a/src/db/migrations/1754226754947-add-upload-status-to-document-entity.ts b/src/db/migrations/1754226754947-add-upload-status-to-document-entity.ts new file mode 100644 index 0000000..10a6b67 --- /dev/null +++ b/src/db/migrations/1754226754947-add-upload-status-to-document-entity.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUploadStatusToDocumentEntity1754226754947 implements MigrationInterface { + name = 'AddUploadStatusToDocumentEntity1754226754947'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "documents" ADD "upload_status" character varying(255) NOT NULL DEFAULT 'NOT_UPLOADED'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "documents" DROP COLUMN "upload_status"`); + } +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 63f3865..85c5217 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -5,3 +5,4 @@ export * from './1753869637732-seed-default-avatar'; export * from './1753874205042-add-neoleap-related-entities'; export * from './1753948642040-add-account-number-and-iban-to-account-entity'; export * from './1754210729273-add-vpan-to-card'; +export * from './1754226754947-add-upload-status-to-document-entity'; diff --git a/src/document/controllers/document.controller.ts b/src/document/controllers/document.controller.ts index 2fc2a72..4393413 100644 --- a/src/document/controllers/document.controller.ts +++ b/src/document/controllers/document.controller.ts @@ -1,9 +1,12 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, HttpCode, HttpStatus, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiNoContentResponse, ApiTags } from '@nestjs/swagger'; +import { IJwtPayload } from '~/auth/interfaces'; +import { AuthenticatedUser } from '~/common/decorators'; import { AccessTokenGuard } from '~/common/guards'; -import { ApiLangRequestHeader } from '~/core/decorators'; +import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators'; import { ResponseFactory } from '~/core/utils'; -import { GenerateUploadSignedUrlRequestDto } from '../dtos/request'; +import { CreateDocumentRequestDto } from '../dtos/request'; +import { GenerateUploadSignedUrlResponseDto } from '../dtos/response'; import { DocumentService } from '../services'; @Controller('document') @ApiTags('Document') @@ -14,8 +17,16 @@ export class DocumentController { constructor(private readonly documentService: DocumentService) {} @Post('signed-url') - async generateSignedUrl(@Body() body: GenerateUploadSignedUrlRequestDto) { - const signedUrl = await this.documentService.generateUploadSignedUrl(body); - return ResponseFactory.data(signedUrl); + @ApiDataResponse(GenerateUploadSignedUrlResponseDto) + async generateSignedUrl(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateDocumentRequestDto) { + const result = await this.documentService.generateUploadSignedUrl(sub, body); + return ResponseFactory.data(new GenerateUploadSignedUrlResponseDto(result.document, result.uploadUrl)); + } + + @Post(':documentId/confirm-update') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiNoContentResponse({ description: 'Document update confirmed successfully' }) + async confirmDocumentUpdate(@Param('documentId') documentId: string) { + return this.documentService.confirmDocumentUpdate(documentId); } } diff --git a/src/document/dtos/request/generate-upload-signed-url.request.dto.ts b/src/document/dtos/request/create-document.request.dto.ts similarity index 88% rename from src/document/dtos/request/generate-upload-signed-url.request.dto.ts rename to src/document/dtos/request/create-document.request.dto.ts index 38e83b2..2b0bd63 100644 --- a/src/document/dtos/request/generate-upload-signed-url.request.dto.ts +++ b/src/document/dtos/request/create-document.request.dto.ts @@ -3,12 +3,12 @@ import { IsEnum, IsString } from 'class-validator'; import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { DocumentType } from '~/document/enums'; -export class GenerateUploadSignedUrlRequestDto { +export class CreateDocumentRequestDto { @ApiProperty({ enum: DocumentType }) @IsEnum(DocumentType, { message: i18n('validation.IsEnum', { path: 'general', property: 'document.documentType' }) }) documentType!: DocumentType; - @ApiProperty({ type: String }) + @ApiProperty({ type: String, example: '.jpg' }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'document.document.extension' }) }) extension!: string; diff --git a/src/document/dtos/request/index.ts b/src/document/dtos/request/index.ts index 2dd4fad..7d9b39c 100644 --- a/src/document/dtos/request/index.ts +++ b/src/document/dtos/request/index.ts @@ -1,2 +1 @@ -export * from './generate-upload-signed-url.request.dto'; -export * from './upload-document.request.dto'; +export * from './create-document.request.dto'; diff --git a/src/document/dtos/request/upload-document.request.dto.ts b/src/document/dtos/request/upload-document.request.dto.ts deleted file mode 100644 index 89210fe..0000000 --- a/src/document/dtos/request/upload-document.request.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IsEnum } from 'class-validator'; -import { i18nValidationMessage as i18n } from 'nestjs-i18n'; -import { DocumentType } from '~/document/enums'; - -export class UploadDocumentRequestDto { - @IsEnum(DocumentType, { message: i18n('validation.IsEnum', { path: 'general', property: 'document.documentType' }) }) - documentType!: DocumentType; -} diff --git a/src/document/dtos/response/generate-upload-signed-url.response.dto.ts b/src/document/dtos/response/generate-upload-signed-url.response.dto.ts new file mode 100644 index 0000000..4e2055e --- /dev/null +++ b/src/document/dtos/response/generate-upload-signed-url.response.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Document } from '~/document/entities'; +import { DocumentMetaResponseDto } from './document-meta.response.dto'; + +export class GenerateUploadSignedUrlResponseDto { + @ApiProperty({ type: DocumentMetaResponseDto }) + document!: DocumentMetaResponseDto; + + @ApiProperty() + uploadUrl!: string; + + constructor(document: Document, uploadUrl: string) { + this.document = new DocumentMetaResponseDto(document); + this.uploadUrl = uploadUrl; + } +} diff --git a/src/document/dtos/response/index.ts b/src/document/dtos/response/index.ts index 18a07a3..6422d12 100644 --- a/src/document/dtos/response/index.ts +++ b/src/document/dtos/response/index.ts @@ -1,2 +1,3 @@ export * from './document-meta.response.dto'; +export * from './generate-upload-signed-url.response.dto'; export * from './upload.response.dto'; diff --git a/src/document/entities/document.entity.ts b/src/document/entities/document.entity.ts index 7c327f1..959f63d 100644 --- a/src/document/entities/document.entity.ts +++ b/src/document/entities/document.entity.ts @@ -15,13 +15,16 @@ import { SavingGoal } from '~/saving-goals/entities'; import { Task } from '~/task/entities'; import { TaskSubmission } from '~/task/entities/task-submissions.entity'; import { User } from '~/user/entities'; -import { DocumentType } from '../enums'; +import { DocumentType, UploadStatus } from '../enums'; @Entity('documents') export class Document { @PrimaryGeneratedColumn('uuid') id!: string; + @Column({ type: 'varchar', length: 255, default: UploadStatus.NOT_UPLOADED, name: 'upload_status' }) + uploadStatus!: UploadStatus; + @Column({ type: 'varchar', length: 255 }) name!: string; diff --git a/src/document/enums/index.ts b/src/document/enums/index.ts index 173c116..e725bf4 100644 --- a/src/document/enums/index.ts +++ b/src/document/enums/index.ts @@ -1 +1,2 @@ export * from './document-type.enum'; +export * from './upload-status.enum'; diff --git a/src/document/enums/upload-status.enum.ts b/src/document/enums/upload-status.enum.ts new file mode 100644 index 0000000..c5f049e --- /dev/null +++ b/src/document/enums/upload-status.enum.ts @@ -0,0 +1,4 @@ +export enum UploadStatus { + NOT_UPLOADED = 'NOT_UPLOADED', + UPLOADED = 'UPLOADED', +} diff --git a/src/document/repositories/document.repository.ts b/src/document/repositories/document.repository.ts index 42e383a..bf9f124 100644 --- a/src/document/repositories/document.repository.ts +++ b/src/document/repositories/document.repository.ts @@ -26,4 +26,8 @@ export class DocumentRepository { findDocumentById(id: string) { return this.documentRepository.findOne({ where: { id } }); } + + updateDocument(id: string, updateData: Partial) { + return this.documentRepository.update(id, updateData); + } } diff --git a/src/document/services/document.service.ts b/src/document/services/document.service.ts index bdc72cd..027dd3c 100644 --- a/src/document/services/document.service.ts +++ b/src/document/services/document.service.ts @@ -1,7 +1,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; -import { GenerateUploadSignedUrlRequestDto, UploadDocumentRequestDto } from '../dtos/request'; +import { CreateDocumentRequestDto } from '../dtos/request'; import { Document } from '../entities'; +import { UploadStatus } from '../enums'; import { DocumentRepository } from '../repositories'; import { OciService } from './oci.service'; @@ -9,11 +10,6 @@ import { OciService } from './oci.service'; export class DocumentService { private readonly logger = new Logger(DocumentService.name); constructor(private readonly ociService: OciService, private readonly documentRepository: DocumentRepository) {} - async createDocument(file: Express.Multer.File, uploadedDocumentRequest: UploadDocumentRequestDto, userId: string) { - this.logger.log(`creating document for with type ${uploadedDocumentRequest.documentType}`); - const uploadedFile = await this.ociService.uploadFile(file, uploadedDocumentRequest); - return this.documentRepository.createDocument(userId, uploadedFile); - } findDocuments(where: FindOptionsWhere) { this.logger.log(`finding documents with where clause ${JSON.stringify(where)}`); @@ -25,10 +21,27 @@ export class DocumentService { return this.documentRepository.findDocumentById(id); } - generateUploadSignedUrl(body: GenerateUploadSignedUrlRequestDto) { + async confirmDocumentUpdate(documentId: string) { + const document = await this.documentRepository.findDocumentById(documentId); + if (!document) { + this.logger.error(`Document with id ${documentId} not found`); + throw new Error(`Document with id ${documentId} not found.`); + } + this.logger.log(`Confirming document update for document id ${documentId}`); + + return this.documentRepository.updateDocument(documentId, { + uploadStatus: UploadStatus.UPLOADED, + }); + } + + async generateUploadSignedUrl(userId: string, body: CreateDocumentRequestDto) { this.logger.log( `generating signed URL for document type ${body.documentType} with original file name ${body.originalFileName}`, ); - return this.ociService.generateUploadPreSignedUrl(body); + const uploadResult = await this.ociService.generateUploadPreSignedUrl(body); + + const document = await this.documentRepository.createDocument(userId, uploadResult); + + return { document, uploadUrl: uploadResult.url }; } } diff --git a/src/document/services/oci.service.ts b/src/document/services/oci.service.ts index 9c6002d..ad2f319 100644 --- a/src/document/services/oci.service.ts +++ b/src/document/services/oci.service.ts @@ -5,10 +5,9 @@ import moment from 'moment'; import { Region, SimpleAuthenticationDetailsProvider } from 'oci-common'; import { ObjectStorageClient } from 'oci-objectstorage'; import { CreatePreauthenticatedRequestDetails } from 'oci-objectstorage/lib/model'; -import path from 'path'; import { CacheService } from '~/common/modules/cache/services'; import { BUCKETS } from '../constants'; -import { GenerateUploadSignedUrlRequestDto, UploadDocumentRequestDto } from '../dtos/request'; +import { CreateDocumentRequestDto } from '../dtos/request'; import { UploadResponseDto } from '../dtos/response'; import { Document } from '../entities'; import { generateNewFileName } from '../utils'; @@ -37,35 +36,6 @@ export class OciService { }); } - async uploadFile(file: Express.Multer.File, { documentType }: UploadDocumentRequestDto): Promise { - this.logger.log(`Uploading file with type ${documentType}`); - const bucketName = BUCKETS[documentType]; - const objectName = generateNewFileName(file.originalname); - - if (!bucketName) { - this.logger.error('Could not find bucket name for document type', documentType); - throw new BadRequestException('DOCUMENT.TYPE_NOT_SUPPORTED'); - } - - this.logger.debug(`Uploading file to bucket ${bucketName} with object name ${objectName}`); - await this.ociClient.putObject({ - namespaceName: this.namespace, - bucketName, - putObjectBody: file.buffer, - contentLength: file.buffer.length, - objectName, - }); - - this.logger.log(`File uploaded successfully to bucket ${bucketName} with object name ${objectName}`); - - return plainToInstance(UploadResponseDto, { - name: objectName, - extension: path.extname(file.originalname), - url: `https://objectstorage.${this.region}.oraclecloud.com/n/${this.namespace}/b/${bucketName}/o/${objectName}`, - documentType, - }); - } - async generatePreSignedUrl(document?: Document): Promise { this.logger.log(`Generating pre-signed url for document ${document?.id}`); if (!document) { @@ -111,7 +81,7 @@ export class OciService { documentType, originalFileName, extension, - }: GenerateUploadSignedUrlRequestDto): Promise { + }: CreateDocumentRequestDto): Promise { const bucketName = BUCKETS[documentType]; if (!bucketName) {