paymnet methods cards new table

This commit is contained in:
2026-06-28 22:41:50 +03:30
parent a565f93044
commit 604447d3e8
10 changed files with 262 additions and 111 deletions
@@ -0,0 +1,103 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260627120000_add_payment_method_cards_table extends Migration {
override async up(): Promise<void> {
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<void> {
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;`);
}
}
@@ -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 { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { PaymentMethodEnum, PaymentGatewayEnum } from '../interface/payment'; import { PaymentMethodEnum, PaymentGatewayEnum } from '../interface/payment';
import { PaymentMethodCardDto } from './payment-method-card.dto';
export class CreatePaymentMethodDto { export class CreatePaymentMethodDto {
@@ -18,36 +20,13 @@ export class CreatePaymentMethodDto {
@IsString() @IsString()
description?: string; description?: string;
@ApiProperty({ description: 'Card number for card-to-card payments', required: false }) @ApiProperty({ description: 'Cards for card-to-card payments', type: [PaymentMethodCardDto], 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 })
@ValidateIf((dto: CreatePaymentMethodDto) => dto.method === PaymentMethodEnum.CreditCard) @ValidateIf((dto: CreatePaymentMethodDto) => dto.method === PaymentMethodEnum.CreditCard)
@IsNotEmpty() @IsArray()
@IsString() @ArrayMinSize(1)
cardOwner?: string; @ValidateNested({ each: true })
@Type(() => PaymentMethodCardDto)
@ApiProperty({ description: 'Second card owner name for card-to-card payments', required: false }) cards?: PaymentMethodCardDto[];
@IsOptional()
@IsString()
cardOwner2?: string;
@ApiProperty({ description: 'Third card owner name for card-to-card payments', required: false })
@IsOptional()
@IsString()
cardOwner3?: string;
@ApiProperty({ description: 'Is payment method enabled', required: false, default: true }) @ApiProperty({ description: 'Is payment method enabled', required: false, default: true })
@IsOptional() @IsOptional()
@@ -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;
}
@@ -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;
}
@@ -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 { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity'; import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
import { PaymentGatewayEnum, PaymentMethodEnum } from '../interface/payment'; import { PaymentGatewayEnum, PaymentMethodEnum } from '../interface/payment';
import { PaymentMethodCard } from './payment-method-card.entity';
@Entity({ tableName: 'payment_methods' }) @Entity({ tableName: 'payment_methods' })
@Unique({ properties: ['restaurant', 'method'] }) @Unique({ properties: ['restaurant', 'method'] })
@@ -20,23 +21,11 @@ export class PaymentMethod extends BaseEntity {
@Property({ nullable: true }) @Property({ nullable: true })
description?: string; description?: string;
@Property({ nullable: true }) @OneToMany(() => PaymentMethodCard, card => card.paymentMethod, {
cardNumber?: string; cascade: [Cascade.ALL],
orphanRemoval: true,
@Property({ nullable: true }) })
cardNumber2?: string; cards = new Collection<PaymentMethodCard>(this);
@Property({ nullable: true })
cardNumber3?: string;
@Property({ nullable: true })
cardOwner?: string;
@Property({ nullable: true })
cardOwner2?: string;
@Property({ nullable: true })
cardOwner3?: string;
@Property({ default: true }) @Property({ default: true })
enabled: boolean = true; enabled: boolean = true;
+2 -1
View File
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
import { PaymentsService } from './services/payments.service'; import { PaymentsService } from './services/payments.service';
import { MikroOrmModule } from '@mikro-orm/nestjs'; import { MikroOrmModule } from '@mikro-orm/nestjs';
import { PaymentMethod } from './entities/payment-method.entity'; import { PaymentMethod } from './entities/payment-method.entity';
import { PaymentMethodCard } from './entities/payment-method-card.entity';
import { PaymentMethodRepository } from './repositories/payment-method.repository'; import { PaymentMethodRepository } from './repositories/payment-method.repository';
import { PaymentMethodService } from './services/payment-method.service'; import { PaymentMethodService } from './services/payment-method.service';
import { Restaurant } from '../restaurants/entities/restaurant.entity'; import { Restaurant } from '../restaurants/entities/restaurant.entity';
@@ -20,7 +21,7 @@ import { OrdersModule } from '../orders/orders.module';
import { RestaurantsModule } from '../restaurants/restaurants.module'; import { RestaurantsModule } from '../restaurants/restaurants.module';
@Module({ @Module({
imports: [MikroOrmModule.forFeature([PaymentMethod, Payment, Restaurant]), imports: [MikroOrmModule.forFeature([PaymentMethod, PaymentMethodCard, Payment, Restaurant]),
AuthModule, JwtModule, InventoryModule, AdminModule, NotificationsModule, AuthModule, JwtModule, InventoryModule, AdminModule, NotificationsModule,
RestaurantsModule, RestaurantsModule,
forwardRef(() => OrdersModule)], forwardRef(() => OrdersModule)],
@@ -1,9 +1,11 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql'; import { EntityManager } from '@mikro-orm/postgresql';
import { PaymentMethod } from '../entities/payment-method.entity'; import { PaymentMethod } from '../entities/payment-method.entity';
import { PaymentMethodCard } from '../entities/payment-method-card.entity';
import { PaymentMethodRepository } from '../repositories/payment-method.repository'; import { PaymentMethodRepository } from '../repositories/payment-method.repository';
import { CreatePaymentMethodDto } from '../dto/create-payment-method.dto'; import { CreatePaymentMethodDto } from '../dto/create-payment-method.dto';
import { UpdatePaymentMethodDto } from '../dto/update-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 { RequiredEntityData } from '@mikro-orm/core';
import { PaymentMessage } from 'src/common/enums/message.enum'; import { PaymentMessage } from 'src/common/enums/message.enum';
import { RestaurantsService } from 'src/modules/restaurants/providers/restaurants.service'; import { RestaurantsService } from 'src/modules/restaurants/providers/restaurants.service';
@@ -18,20 +20,23 @@ export class PaymentMethodService {
) { } ) { }
async create(restId: string, createPaymentMethodDto: CreatePaymentMethodDto): Promise<PaymentMethod> { async create(restId: string, createPaymentMethodDto: CreatePaymentMethodDto): Promise<PaymentMethod> {
// Check if restaurant exists
const restaurant = await this.restaurantsService.findOneOrFail(restId); 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({ const paymentMethod = this.paymentMethodRepository.create({
restaurant, restaurant,
...createPaymentMethodDto, ...paymentMethodData,
} as unknown as RequiredEntityData<PaymentMethod>); } as unknown as RequiredEntityData<PaymentMethod>);
this.assignCards(paymentMethod, cards);
await this.em.persistAndFlush(paymentMethod); await this.em.persistAndFlush(paymentMethod);
return paymentMethod; return paymentMethod;
} }
async findOneOrFail(id: string): Promise<PaymentMethod> { async findOneOrFail(id: string): Promise<PaymentMethod> {
const paymentMethod = await this.paymentMethodRepository.findOne({ id }, { populate: ['restaurant'] }); const paymentMethod = await this.paymentMethodRepository.findOne({ id }, { populate: ['restaurant', 'cards'] });
if (!paymentMethod) { if (!paymentMethod) {
throw new NotFoundException(PaymentMessage.PAYMENT_METHOD_NOT_FOUND); throw new NotFoundException(PaymentMessage.PAYMENT_METHOD_NOT_FOUND);
} }
@@ -39,19 +44,55 @@ export class PaymentMethodService {
} }
async findByRestaurant(restaurantId: string): Promise<PaymentMethod[]> { async findByRestaurant(restaurantId: string): Promise<PaymentMethod[]> {
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<PaymentMethod> { async update(id: string, updatePaymentMethodDto: UpdatePaymentMethodDto): Promise<PaymentMethod> {
const paymentMethod = await this.findOneOrFail(id); const paymentMethod = await this.findOneOrFail(id);
this.em.assign(paymentMethod, updatePaymentMethodDto); const { cards, ...paymentMethodData } = updatePaymentMethodDto;
this.assertCreditCardOwner(paymentMethod.method, paymentMethod.cardOwner);
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(); await this.em.flush();
return paymentMethod; return paymentMethod;
} }
private assertCreditCardOwner(method: PaymentMethodEnum, cardOwner?: string): void { private assignCards(paymentMethod: PaymentMethod, cards?: PaymentMethodCardDto[]): void {
if (method === PaymentMethodEnum.CreditCard && !cardOwner?.trim()) { 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); throw new BadRequestException(PaymentMessage.CARD_OWNER_REQUIRED);
} }
} }
@@ -61,6 +102,4 @@ export class PaymentMethodService {
await this.em.removeAndFlush(paymentMethod); await this.em.removeAndFlush(paymentMethod);
return paymentMethod; return paymentMethod;
} }
} }
@@ -57,7 +57,7 @@ export class PaymentsService {
const order = await this.em.findOne( const order = await this.em.findOne(
Order, Order,
{ id: orderId }, { id: orderId },
{ populate: ['user', 'restaurant', 'paymentMethod', 'paymentMethod.restaurant'] }, { populate: ['user', 'restaurant', 'paymentMethod', 'paymentMethod.restaurant', 'paymentMethod.cards'] },
); );
if (!order) { if (!order) {
@@ -79,13 +79,6 @@ export class PaymentsService {
if (!pm.restaurant?.domain) throw new BadRequestException(PaymentMessage.RESTAURANT_DOMAIN_REQUIRED); 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 { return {
order, order,
+8 -8
View File
@@ -1,4 +1,5 @@
import { PaymentMethodEnum, PaymentGatewayEnum } from '../../modules/payments/interface/payment'; import { PaymentMethodEnum, PaymentGatewayEnum } from '../../modules/payments/interface/payment';
import { PaymentMethodCardDto } from '../../modules/payments/dto/payment-method-card.dto';
export interface PaymentMethodData { export interface PaymentMethodData {
method: PaymentMethodEnum; method: PaymentMethodEnum;
@@ -6,12 +7,7 @@ export interface PaymentMethodData {
enabled: boolean; enabled: boolean;
order: number; order: number;
gateway: PaymentGatewayEnum | null; gateway: PaymentGatewayEnum | null;
cardNumber?: string; cards?: PaymentMethodCardDto[];
cardNumber2?: string;
cardNumber3?: string;
cardOwner?: string;
cardOwner2?: string;
cardOwner3?: string;
merchantId?: string; merchantId?: string;
} }
@@ -44,7 +40,11 @@ export const paymentMethodsData: PaymentMethodData[] = [
enabled: true, enabled: true,
order: 4, order: 4,
gateway: null, gateway: null,
cardNumber: '6037991234567890', cards: [
cardOwner: 'صاحب کارت', {
cardNumber: '6037991234567890',
owner: 'صاحب کارت',
},
],
}, },
]; ];
+52 -35
View File
@@ -1,18 +1,41 @@
import type { EntityManager } from '@mikro-orm/core'; import type { EntityManager } from '@mikro-orm/core';
import { PaymentMethod } from '../modules/payments/entities/payment-method.entity'; 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 type { Restaurant } from '../modules/restaurants/entities/restaurant.entity';
import { paymentMethodsData } from './data/payment-methods.data'; 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 { export class PaymentMethodsSeeder {
async run(em: EntityManager, restaurants: Restaurant[]): Promise<void> { async run(em: EntityManager, restaurants: Restaurant[]): Promise<void> {
for (const restaurant of restaurants) { for (const restaurant of restaurants) {
if (!restaurant) continue; if (!restaurant) continue;
for (const methodData of paymentMethodsData) { for (const methodData of paymentMethodsData) {
const existing = await em.findOne(PaymentMethod, { const existing = await em.findOne(
restaurant: { id: restaurant.id }, PaymentMethod,
method: methodData.method, {
}); restaurant: { id: restaurant.id },
method: methodData.method,
},
{ populate: ['cards'] },
);
if (!existing) { if (!existing) {
const paymentMethod = em.create(PaymentMethod, { const paymentMethod = em.create(PaymentMethod, {
@@ -22,27 +45,27 @@ export class PaymentMethodsSeeder {
enabled: methodData.enabled, enabled: methodData.enabled,
order: methodData.order, order: methodData.order,
gateway: methodData.gateway, gateway: methodData.gateway,
cardNumber: methodData.cardNumber,
cardNumber2: methodData.cardNumber2,
cardNumber3: methodData.cardNumber3,
cardOwner: methodData.cardOwner,
cardOwner2: methodData.cardOwner2,
cardOwner3: methodData.cardOwner3,
merchantId: methodData.merchantId, 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); em.persist(paymentMethod);
} else { } else {
// Update existing payment method if needed const existingCards = existing.cards.getItems();
if ( if (
existing.description !== methodData.description || existing.description !== methodData.description ||
existing.enabled !== methodData.enabled || existing.enabled !== methodData.enabled ||
existing.order !== methodData.order || existing.order !== methodData.order ||
existing.cardNumber !== methodData.cardNumber || !cardsMatch(existingCards, methodData.cards)
existing.cardNumber2 !== methodData.cardNumber2 ||
existing.cardNumber3 !== methodData.cardNumber3 ||
existing.cardOwner !== methodData.cardOwner ||
existing.cardOwner2 !== methodData.cardOwner2 ||
existing.cardOwner3 !== methodData.cardOwner3
) { ) {
existing.description = methodData.description; existing.description = methodData.description;
existing.enabled = methodData.enabled; existing.enabled = methodData.enabled;
@@ -50,27 +73,21 @@ export class PaymentMethodsSeeder {
if (methodData.gateway !== undefined) { if (methodData.gateway !== undefined) {
existing.gateway = methodData.gateway; 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) { if (methodData.merchantId !== undefined) {
existing.merchantId = methodData.merchantId; 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); em.persist(existing);
} }
} }