From 604447d3e831b009249c6fb1b353b40174e891f8 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sun, 28 Jun 2026 22:41:50 +0330 Subject: [PATCH] paymnet methods cards new table --- ...27120000_add_payment_method_cards_table.ts | 103 ++++++++++++++++++ .../payments/dto/create-payment-method.dto.ts | 39 ++----- .../payments/dto/payment-method-card.dto.ts | 14 +++ .../entities/payment-method-card.entity.ts | 16 +++ .../entities/payment-method.entity.ts | 25 ++--- src/modules/payments/payments.module.ts | 3 +- .../services/payment-method.service.ts | 61 +++++++++-- .../payments/services/payments.service.ts | 9 +- src/seeders/data/payment-methods.data.ts | 16 +-- src/seeders/payment-methods.seeder.ts | 87 +++++++++------ 10 files changed, 262 insertions(+), 111 deletions(-) create mode 100644 database/migrations/Migration20260627120000_add_payment_method_cards_table.ts create mode 100644 src/modules/payments/dto/payment-method-card.dto.ts create mode 100644 src/modules/payments/entities/payment-method-card.entity.ts diff --git a/database/migrations/Migration20260627120000_add_payment_method_cards_table.ts b/database/migrations/Migration20260627120000_add_payment_method_cards_table.ts new file mode 100644 index 0000000..355a3c1 --- /dev/null +++ b/database/migrations/Migration20260627120000_add_payment_method_cards_table.ts @@ -0,0 +1,103 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260627120000_add_payment_method_cards_table extends Migration { + + override async up(): Promise { + this.addSql(` + create table "payment_method_cards" ( + "id" char(26) not null, + "created_at" timestamptz not null default now(), + "updated_at" timestamptz not null default now(), + "deleted_at" timestamptz null, + "payment_method_id" char(26) not null, + "card_number" varchar(255) not null, + "owner" varchar(255) not null, + constraint "payment_method_cards_pkey" primary key ("id") + ); + `); + this.addSql(`create index "payment_method_cards_payment_method_id_index" on "payment_method_cards" ("payment_method_id");`); + this.addSql(`create index "payment_method_cards_deleted_at_index" on "payment_method_cards" ("deleted_at");`); + this.addSql(`create index "payment_method_cards_created_at_index" on "payment_method_cards" ("created_at");`); + this.addSql(` + alter table "payment_method_cards" + add constraint "payment_method_cards_payment_method_id_foreign" + foreign key ("payment_method_id") references "payment_methods" ("id") + on update cascade on delete cascade; + `); + + this.addSql(` + insert into "payment_method_cards" ("id", "created_at", "updated_at", "payment_method_id", "card_number", "owner") + select substring(replace(gen_random_uuid()::text, '-', ''), 1, 26), now(), now(), "id", "card_number", "card_owner" + from "payment_methods" + where "card_number" is not null and "card_owner" is not null; + `); + this.addSql(` + insert into "payment_method_cards" ("id", "created_at", "updated_at", "payment_method_id", "card_number", "owner") + select substring(replace(gen_random_uuid()::text, '-', ''), 1, 26), now(), now(), "id", "card_number2", "card_owner2" + from "payment_methods" + where "card_number2" is not null and "card_owner2" is not null; + `); + this.addSql(` + insert into "payment_method_cards" ("id", "created_at", "updated_at", "payment_method_id", "card_number", "owner") + select substring(replace(gen_random_uuid()::text, '-', ''), 1, 26), now(), now(), "id", "card_number3", "card_owner3" + from "payment_methods" + where "card_number3" is not null and "card_owner3" is not null; + `); + + this.addSql(` + alter table "payment_methods" + drop column "card_number", + drop column "card_number2", + drop column "card_number3", + drop column "card_owner", + drop column "card_owner2", + drop column "card_owner3"; + `); + } + + override async down(): Promise { + this.addSql(` + alter table "payment_methods" + add column "card_number" varchar(255) null, + add column "card_number2" varchar(255) null, + add column "card_number3" varchar(255) null, + add column "card_owner" varchar(255) null, + add column "card_owner2" varchar(255) null, + add column "card_owner3" varchar(255) null; + `); + + this.addSql(` + update "payment_methods" pm + set + "card_number" = c1."card_number", + "card_owner" = c1."owner", + "card_number2" = c2."card_number", + "card_owner2" = c2."owner", + "card_number3" = c3."card_number", + "card_owner3" = c3."owner" + from ( + select distinct on ("payment_method_id") * + from "payment_method_cards" + order by "payment_method_id", "created_at" + ) c1 + left join lateral ( + select * + from "payment_method_cards" + where "payment_method_id" = c1."payment_method_id" + order by "created_at" + offset 1 limit 1 + ) c2 on true + left join lateral ( + select * + from "payment_method_cards" + where "payment_method_id" = c1."payment_method_id" + order by "created_at" + offset 2 limit 1 + ) c3 on true + where pm."id" = c1."payment_method_id"; + `); + + this.addSql(`drop table if exists "payment_method_cards" cascade;`); + } + +} diff --git a/src/modules/payments/dto/create-payment-method.dto.ts b/src/modules/payments/dto/create-payment-method.dto.ts index f40c5a2..8559059 100644 --- a/src/modules/payments/dto/create-payment-method.dto.ts +++ b/src/modules/payments/dto/create-payment-method.dto.ts @@ -1,6 +1,8 @@ -import { IsString, IsOptional, IsBoolean, IsNumber, IsEnum, ValidateIf, IsNotEmpty } from 'class-validator'; +import { IsString, IsOptional, IsBoolean, IsNumber, IsEnum, ValidateIf, IsArray, ValidateNested, ArrayMinSize } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; import { PaymentMethodEnum, PaymentGatewayEnum } from '../interface/payment'; +import { PaymentMethodCardDto } from './payment-method-card.dto'; export class CreatePaymentMethodDto { @@ -18,36 +20,13 @@ export class CreatePaymentMethodDto { @IsString() description?: string; - @ApiProperty({ description: 'Card number for card-to-card payments', required: false }) - @IsOptional() - @IsString() - cardNumber?: string; - - @ApiProperty({ description: 'Second card number for card-to-card payments', required: false }) - @IsOptional() - @IsString() - cardNumber2?: string; - - @ApiProperty({ description: 'Third card number for card-to-card payments', required: false }) - @IsOptional() - @IsString() - cardNumber3?: string; - - @ApiProperty({ description: 'Card owner name for card-to-card payments', required: false }) + @ApiProperty({ description: 'Cards for card-to-card payments', type: [PaymentMethodCardDto], required: false }) @ValidateIf((dto: CreatePaymentMethodDto) => dto.method === PaymentMethodEnum.CreditCard) - @IsNotEmpty() - @IsString() - cardOwner?: string; - - @ApiProperty({ description: 'Second card owner name for card-to-card payments', required: false }) - @IsOptional() - @IsString() - cardOwner2?: string; - - @ApiProperty({ description: 'Third card owner name for card-to-card payments', required: false }) - @IsOptional() - @IsString() - cardOwner3?: string; + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => PaymentMethodCardDto) + cards?: PaymentMethodCardDto[]; @ApiProperty({ description: 'Is payment method enabled', required: false, default: true }) @IsOptional() diff --git a/src/modules/payments/dto/payment-method-card.dto.ts b/src/modules/payments/dto/payment-method-card.dto.ts new file mode 100644 index 0000000..e83a4ef --- /dev/null +++ b/src/modules/payments/dto/payment-method-card.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class PaymentMethodCardDto { + @ApiProperty({ description: 'Card number for card-to-card payments' }) + @IsNotEmpty() + @IsString() + cardNumber!: string; + + @ApiProperty({ description: 'Card owner name for card-to-card payments' }) + @IsNotEmpty() + @IsString() + owner!: string; +} diff --git a/src/modules/payments/entities/payment-method-card.entity.ts b/src/modules/payments/entities/payment-method-card.entity.ts new file mode 100644 index 0000000..38709a5 --- /dev/null +++ b/src/modules/payments/entities/payment-method-card.entity.ts @@ -0,0 +1,16 @@ +import { Entity, Index, ManyToOne, Property } from '@mikro-orm/core'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { PaymentMethod } from './payment-method.entity'; + +@Entity({ tableName: 'payment_method_cards' }) +@Index({ properties: ['paymentMethod'] }) +export class PaymentMethodCard extends BaseEntity { + @ManyToOne(() => PaymentMethod) + paymentMethod!: PaymentMethod; + + @Property() + cardNumber!: string; + + @Property() + owner!: string; +} diff --git a/src/modules/payments/entities/payment-method.entity.ts b/src/modules/payments/entities/payment-method.entity.ts index 87e5a24..ec92a34 100644 --- a/src/modules/payments/entities/payment-method.entity.ts +++ b/src/modules/payments/entities/payment-method.entity.ts @@ -1,7 +1,8 @@ -import { Entity, Enum, Index, ManyToOne, Property, Unique } from '@mikro-orm/core'; +import { Cascade, Collection, Entity, Enum, Index, ManyToOne, OneToMany, Property, Unique } from '@mikro-orm/core'; import { BaseEntity } from '../../../common/entities/base.entity'; import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity'; import { PaymentGatewayEnum, PaymentMethodEnum } from '../interface/payment'; +import { PaymentMethodCard } from './payment-method-card.entity'; @Entity({ tableName: 'payment_methods' }) @Unique({ properties: ['restaurant', 'method'] }) @@ -20,23 +21,11 @@ export class PaymentMethod extends BaseEntity { @Property({ nullable: true }) description?: string; - @Property({ nullable: true }) - cardNumber?: string; - - @Property({ nullable: true }) - cardNumber2?: string; - - @Property({ nullable: true }) - cardNumber3?: string; - - @Property({ nullable: true }) - cardOwner?: string; - - @Property({ nullable: true }) - cardOwner2?: string; - - @Property({ nullable: true }) - cardOwner3?: string; + @OneToMany(() => PaymentMethodCard, card => card.paymentMethod, { + cascade: [Cascade.ALL], + orphanRemoval: true, + }) + cards = new Collection(this); @Property({ default: true }) enabled: boolean = true; diff --git a/src/modules/payments/payments.module.ts b/src/modules/payments/payments.module.ts index 8165b65..dde7cd9 100644 --- a/src/modules/payments/payments.module.ts +++ b/src/modules/payments/payments.module.ts @@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { PaymentsService } from './services/payments.service'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { PaymentMethod } from './entities/payment-method.entity'; +import { PaymentMethodCard } from './entities/payment-method-card.entity'; import { PaymentMethodRepository } from './repositories/payment-method.repository'; import { PaymentMethodService } from './services/payment-method.service'; import { Restaurant } from '../restaurants/entities/restaurant.entity'; @@ -20,7 +21,7 @@ import { OrdersModule } from '../orders/orders.module'; import { RestaurantsModule } from '../restaurants/restaurants.module'; @Module({ - imports: [MikroOrmModule.forFeature([PaymentMethod, Payment, Restaurant]), + imports: [MikroOrmModule.forFeature([PaymentMethod, PaymentMethodCard, Payment, Restaurant]), AuthModule, JwtModule, InventoryModule, AdminModule, NotificationsModule, RestaurantsModule, forwardRef(() => OrdersModule)], diff --git a/src/modules/payments/services/payment-method.service.ts b/src/modules/payments/services/payment-method.service.ts index 8d671e0..dd3cb65 100644 --- a/src/modules/payments/services/payment-method.service.ts +++ b/src/modules/payments/services/payment-method.service.ts @@ -1,9 +1,11 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { EntityManager } from '@mikro-orm/postgresql'; import { PaymentMethod } from '../entities/payment-method.entity'; +import { PaymentMethodCard } from '../entities/payment-method-card.entity'; import { PaymentMethodRepository } from '../repositories/payment-method.repository'; import { CreatePaymentMethodDto } from '../dto/create-payment-method.dto'; import { UpdatePaymentMethodDto } from '../dto/update-payment-method.dto'; +import { PaymentMethodCardDto } from '../dto/payment-method-card.dto'; import { RequiredEntityData } from '@mikro-orm/core'; import { PaymentMessage } from 'src/common/enums/message.enum'; import { RestaurantsService } from 'src/modules/restaurants/providers/restaurants.service'; @@ -18,20 +20,23 @@ export class PaymentMethodService { ) { } async create(restId: string, createPaymentMethodDto: CreatePaymentMethodDto): Promise { - // Check if restaurant exists const restaurant = await this.restaurantsService.findOneOrFail(restId); - this.assertCreditCardOwner(createPaymentMethodDto.method, createPaymentMethodDto.cardOwner); + const { cards, ...paymentMethodData } = createPaymentMethodDto; + this.assertCreditCards(createPaymentMethodDto.method, cards); const paymentMethod = this.paymentMethodRepository.create({ restaurant, - ...createPaymentMethodDto, + ...paymentMethodData, } as unknown as RequiredEntityData); + + this.assignCards(paymentMethod, cards); + await this.em.persistAndFlush(paymentMethod); return paymentMethod; } async findOneOrFail(id: string): Promise { - const paymentMethod = await this.paymentMethodRepository.findOne({ id }, { populate: ['restaurant'] }); + const paymentMethod = await this.paymentMethodRepository.findOne({ id }, { populate: ['restaurant', 'cards'] }); if (!paymentMethod) { throw new NotFoundException(PaymentMessage.PAYMENT_METHOD_NOT_FOUND); } @@ -39,19 +44,55 @@ export class PaymentMethodService { } async findByRestaurant(restaurantId: string): Promise { - return this.paymentMethodRepository.find({ restaurant: { id: restaurantId } }, { populate: ['restaurant'] }); + return this.paymentMethodRepository.find( + { restaurant: { id: restaurantId } }, + { populate: ['restaurant', 'cards'] }, + ); } async update(id: string, updatePaymentMethodDto: UpdatePaymentMethodDto): Promise { const paymentMethod = await this.findOneOrFail(id); - this.em.assign(paymentMethod, updatePaymentMethodDto); - this.assertCreditCardOwner(paymentMethod.method, paymentMethod.cardOwner); + const { cards, ...paymentMethodData } = updatePaymentMethodDto; + + this.em.assign(paymentMethod, paymentMethodData); + + if (cards !== undefined) { + this.assignCards(paymentMethod, cards); + } + + this.assertCreditCards( + paymentMethod.method, + paymentMethod.cards.getItems().map(card => ({ cardNumber: card.cardNumber, owner: card.owner })), + ); + await this.em.flush(); return paymentMethod; } - private assertCreditCardOwner(method: PaymentMethodEnum, cardOwner?: string): void { - if (method === PaymentMethodEnum.CreditCard && !cardOwner?.trim()) { + private assignCards(paymentMethod: PaymentMethod, cards?: PaymentMethodCardDto[]): void { + if (!cards) { + return; + } + + paymentMethod.cards.removeAll(); + for (const card of cards) { + paymentMethod.cards.add( + this.em.create(PaymentMethodCard, { + paymentMethod, + cardNumber: card.cardNumber, + owner: card.owner, + }), + ); + } + } + + private assertCreditCards(method: PaymentMethodEnum, cards?: PaymentMethodCardDto[]): void { + if (method !== PaymentMethodEnum.CreditCard) { + return; + } + + const hasValidCard = cards?.some(card => card.cardNumber?.trim() && card.owner?.trim()); + if (!hasValidCard) { throw new BadRequestException(PaymentMessage.CARD_OWNER_REQUIRED); } } @@ -61,6 +102,4 @@ export class PaymentMethodService { await this.em.removeAndFlush(paymentMethod); return paymentMethod; } - - } diff --git a/src/modules/payments/services/payments.service.ts b/src/modules/payments/services/payments.service.ts index dcb20c8..0ee6c88 100644 --- a/src/modules/payments/services/payments.service.ts +++ b/src/modules/payments/services/payments.service.ts @@ -57,7 +57,7 @@ export class PaymentsService { const order = await this.em.findOne( Order, { id: orderId }, - { populate: ['user', 'restaurant', 'paymentMethod', 'paymentMethod.restaurant'] }, + { populate: ['user', 'restaurant', 'paymentMethod', 'paymentMethod.restaurant', 'paymentMethod.cards'] }, ); if (!order) { @@ -79,13 +79,6 @@ export class PaymentsService { if (!pm.restaurant?.domain) throw new BadRequestException(PaymentMessage.RESTAURANT_DOMAIN_REQUIRED); } - if (pm.method === PaymentMethodEnum.CreditCard && !pm.cardNumber) { - throw new BadRequestException(PaymentMessage.CARD_NUMBER_REQUIRED); - } - - if (pm.method === PaymentMethodEnum.CreditCard && !pm.cardOwner?.trim()) { - throw new BadRequestException(PaymentMessage.CARD_OWNER_REQUIRED); - } return { order, diff --git a/src/seeders/data/payment-methods.data.ts b/src/seeders/data/payment-methods.data.ts index 3a02109..de42433 100644 --- a/src/seeders/data/payment-methods.data.ts +++ b/src/seeders/data/payment-methods.data.ts @@ -1,4 +1,5 @@ import { PaymentMethodEnum, PaymentGatewayEnum } from '../../modules/payments/interface/payment'; +import { PaymentMethodCardDto } from '../../modules/payments/dto/payment-method-card.dto'; export interface PaymentMethodData { method: PaymentMethodEnum; @@ -6,12 +7,7 @@ export interface PaymentMethodData { enabled: boolean; order: number; gateway: PaymentGatewayEnum | null; - cardNumber?: string; - cardNumber2?: string; - cardNumber3?: string; - cardOwner?: string; - cardOwner2?: string; - cardOwner3?: string; + cards?: PaymentMethodCardDto[]; merchantId?: string; } @@ -44,7 +40,11 @@ export const paymentMethodsData: PaymentMethodData[] = [ enabled: true, order: 4, gateway: null, - cardNumber: '6037991234567890', - cardOwner: 'صاحب کارت', + cards: [ + { + cardNumber: '6037991234567890', + owner: 'صاحب کارت', + }, + ], }, ]; diff --git a/src/seeders/payment-methods.seeder.ts b/src/seeders/payment-methods.seeder.ts index d2a5e2c..d6d4f2a 100644 --- a/src/seeders/payment-methods.seeder.ts +++ b/src/seeders/payment-methods.seeder.ts @@ -1,18 +1,41 @@ import type { EntityManager } from '@mikro-orm/core'; import { PaymentMethod } from '../modules/payments/entities/payment-method.entity'; +import { PaymentMethodCard } from '../modules/payments/entities/payment-method-card.entity'; import type { Restaurant } from '../modules/restaurants/entities/restaurant.entity'; import { paymentMethodsData } from './data/payment-methods.data'; +function cardsMatch( + existing: PaymentMethodCard[], + expected: { cardNumber: string; owner: string }[] | undefined, +): boolean { + if (!expected?.length) { + return existing.length === 0; + } + + if (existing.length !== expected.length) { + return false; + } + + return expected.every((card, index) => { + const current = existing[index]; + return current.cardNumber === card.cardNumber && current.owner === card.owner; + }); +} + export class PaymentMethodsSeeder { async run(em: EntityManager, restaurants: Restaurant[]): Promise { for (const restaurant of restaurants) { if (!restaurant) continue; for (const methodData of paymentMethodsData) { - const existing = await em.findOne(PaymentMethod, { - restaurant: { id: restaurant.id }, - method: methodData.method, - }); + const existing = await em.findOne( + PaymentMethod, + { + restaurant: { id: restaurant.id }, + method: methodData.method, + }, + { populate: ['cards'] }, + ); if (!existing) { const paymentMethod = em.create(PaymentMethod, { @@ -22,27 +45,27 @@ export class PaymentMethodsSeeder { enabled: methodData.enabled, order: methodData.order, gateway: methodData.gateway, - cardNumber: methodData.cardNumber, - cardNumber2: methodData.cardNumber2, - cardNumber3: methodData.cardNumber3, - cardOwner: methodData.cardOwner, - cardOwner2: methodData.cardOwner2, - cardOwner3: methodData.cardOwner3, merchantId: methodData.merchantId, }); + + for (const card of methodData.cards ?? []) { + paymentMethod.cards.add( + em.create(PaymentMethodCard, { + paymentMethod, + cardNumber: card.cardNumber, + owner: card.owner, + }), + ); + } + em.persist(paymentMethod); } else { - // Update existing payment method if needed + const existingCards = existing.cards.getItems(); if ( existing.description !== methodData.description || existing.enabled !== methodData.enabled || existing.order !== methodData.order || - existing.cardNumber !== methodData.cardNumber || - existing.cardNumber2 !== methodData.cardNumber2 || - existing.cardNumber3 !== methodData.cardNumber3 || - existing.cardOwner !== methodData.cardOwner || - existing.cardOwner2 !== methodData.cardOwner2 || - existing.cardOwner3 !== methodData.cardOwner3 + !cardsMatch(existingCards, methodData.cards) ) { existing.description = methodData.description; existing.enabled = methodData.enabled; @@ -50,27 +73,21 @@ export class PaymentMethodsSeeder { if (methodData.gateway !== undefined) { existing.gateway = methodData.gateway; } - if (methodData.cardNumber !== undefined) { - existing.cardNumber = methodData.cardNumber; - } - if (methodData.cardNumber2 !== undefined) { - existing.cardNumber2 = methodData.cardNumber2; - } - if (methodData.cardNumber3 !== undefined) { - existing.cardNumber3 = methodData.cardNumber3; - } - if (methodData.cardOwner !== undefined) { - existing.cardOwner = methodData.cardOwner; - } - if (methodData.cardOwner2 !== undefined) { - existing.cardOwner2 = methodData.cardOwner2; - } - if (methodData.cardOwner3 !== undefined) { - existing.cardOwner3 = methodData.cardOwner3; - } if (methodData.merchantId !== undefined) { existing.merchantId = methodData.merchantId; } + if (methodData.cards !== undefined) { + existing.cards.removeAll(); + for (const card of methodData.cards) { + existing.cards.add( + em.create(PaymentMethodCard, { + paymentMethod: existing, + cardNumber: card.cardNumber, + owner: card.owner, + }), + ); + } + } em.persist(existing); } }