From 89fa1d637e55d25c942bbdcede920f60c9461119 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sun, 7 Jun 2026 16:56:55 +0330 Subject: [PATCH] add credit card payment method --- database/migrations/.snapshot-dmenu.json | 25 +++++++++++++-- database/migrations/.snapshot-postgres.json | 13 ++++++-- .../migrations/Migration20260607131845.ts | 31 ++++++++++++++++++ src/common/enums/message.enum.ts | 2 ++ .../cart/dto/set-all-cart-params.dto.ts | 22 ++++++++++++- src/modules/cart/interfaces/cart.interface.ts | 2 ++ src/modules/cart/providers/cart.service.ts | 10 +++++- .../orders/providers/orders.service.ts | 2 ++ .../controllers/payments.controller.ts | 10 ++++++ .../payments/entities/payment.entity.ts | 4 +++ src/modules/payments/interface/payment.ts | 2 +- .../payments/listeners/payment.listeners.ts | 1 + .../payments/services/payments.service.ts | 32 +++++++++++++++++-- src/seeders/data/payment-methods.data.ts | 8 +++++ 14 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 database/migrations/Migration20260607131845.ts diff --git a/database/migrations/.snapshot-dmenu.json b/database/migrations/.snapshot-dmenu.json index 94e4504..19242db 100644 --- a/database/migrations/.snapshot-dmenu.json +++ b/database/migrations/.snapshot-dmenu.json @@ -1076,7 +1076,8 @@ "enumItems": [ "Online", "Cash", - "Wallet" + "Wallet", + "CreditCard" ], "mappedType": "enum" }, @@ -1102,6 +1103,16 @@ "length": 255, "mappedType": "string" }, + "card_number": { + "name": "card_number", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 255, + "mappedType": "string" + }, "enabled": { "name": "enabled", "type": "boolean", @@ -4523,7 +4534,8 @@ "enumItems": [ "Online", "Cash", - "Wallet" + "Wallet", + "CreditCard" ], "mappedType": "enum" }, @@ -4612,6 +4624,15 @@ "nullable": true, "length": 255, "mappedType": "string" + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" } }, "name": "payments", diff --git a/database/migrations/.snapshot-postgres.json b/database/migrations/.snapshot-postgres.json index c82541f..19242db 100644 --- a/database/migrations/.snapshot-postgres.json +++ b/database/migrations/.snapshot-postgres.json @@ -1077,7 +1077,7 @@ "Online", "Cash", "Wallet", - "Cart" + "CreditCard" ], "mappedType": "enum" }, @@ -4535,7 +4535,7 @@ "Online", "Cash", "Wallet", - "Cart" + "CreditCard" ], "mappedType": "enum" }, @@ -4624,6 +4624,15 @@ "nullable": true, "length": 255, "mappedType": "string" + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" } }, "name": "payments", diff --git a/database/migrations/Migration20260607131845.ts b/database/migrations/Migration20260607131845.ts new file mode 100644 index 0000000..7c3869b --- /dev/null +++ b/database/migrations/Migration20260607131845.ts @@ -0,0 +1,31 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260607131845 extends Migration { + + override async up(): Promise { + this.addSql(`update "payment_methods" set "method" = 'CreditCard' where "method" = 'Cart';`); + this.addSql(`update "payments" set "method" = 'CreditCard' where "method" = 'Cart';`); + + this.addSql(`alter table "payment_methods" drop constraint if exists "payment_methods_method_check";`); + this.addSql(`alter table "payments" drop constraint if exists "payments_method_check";`); + + this.addSql(`alter table "payments" add column if not exists "attachments" jsonb null;`); + + this.addSql(`alter table "payment_methods" add constraint "payment_methods_method_check" check("method" in ('Online', 'Cash', 'Wallet', 'CreditCard'));`); + this.addSql(`alter table "payments" add constraint "payments_method_check" check("method" in ('Online', 'Cash', 'Wallet', 'CreditCard'));`); + } + + override async down(): Promise { + this.addSql(`alter table "payment_methods" drop constraint if exists "payment_methods_method_check";`); + this.addSql(`alter table "payments" drop constraint if exists "payments_method_check";`); + + this.addSql(`update "payment_methods" set "method" = 'Cart' where "method" = 'CreditCard';`); + this.addSql(`update "payments" set "method" = 'Cart' where "method" = 'CreditCard';`); + + this.addSql(`alter table "payments" drop column if exists "attachments";`); + + this.addSql(`alter table "payment_methods" add constraint "payment_methods_method_check" check("method" in ('Online', 'Cash', 'Wallet', 'Cart'));`); + this.addSql(`alter table "payments" add constraint "payments_method_check" check("method" in ('Online', 'Cash', 'Wallet', 'Cart'));`); + } + +} diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 51e6175..0e6a91e 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -741,6 +741,8 @@ export const enum PaymentMessage { PAYMENT_NOT_FOUND = 'پرداخت یافت نشد', INVALID_PAYMENT_CONFIGURATION = 'پیکربندی پرداخت نامعتبر است', PAYMENT_METHOD_NOT_CASH = 'روش پرداخت نقدی نیست', + PAYMENT_METHOD_NOT_CREDIT_CARD = 'روش پرداخت کارت به کارت نیست', + CARD_NUMBER_REQUIRED = 'شماره کارت برای پرداخت کارت به کارت الزامی است', PAYMENT_ALREADY_PAID = 'پرداخت قبلا انجام شده است', EXISTING_PENDING_PAYMENT_MISMATCH = 'پرداخت در انتظار موجود با روش پرداخت سفارش مطابقت ندارد', ZARINPAL_INVALID_RESPONSE = 'پاسخ نامعتبر از API زرین‌پال', diff --git a/src/modules/cart/dto/set-all-cart-params.dto.ts b/src/modules/cart/dto/set-all-cart-params.dto.ts index d329a51..3151a67 100644 --- a/src/modules/cart/dto/set-all-cart-params.dto.ts +++ b/src/modules/cart/dto/set-all-cart-params.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; import { SetCarDeliveryDto } from './set-car-delivery.dto'; @@ -37,4 +37,24 @@ export class SetAllCartParmsDto { @IsOptional() @IsObject() carAddress?: SetCarDeliveryDto; + + @ApiProperty({ + description: 'Payment receipt attachments (URLs)', + required: false, + type: [String], + example: ['https://example.com/receipt.jpg'], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + attachments?: string[]; + + @ApiProperty({ + description: 'Payment description or note', + required: false, + example: 'Transfer from account ending 1234', + }) + @IsOptional() + @IsString() + paymentDesc?: string; } diff --git a/src/modules/cart/interfaces/cart.interface.ts b/src/modules/cart/interfaces/cart.interface.ts index b281661..459fdc0 100644 --- a/src/modules/cart/interfaces/cart.interface.ts +++ b/src/modules/cart/interfaces/cart.interface.ts @@ -20,6 +20,8 @@ export interface Cart { deliveryMethodId?: string; description?: string; tableNumber?: string; + attachments?: string[]; + paymentDesc?: string; deliveryFee: number; subTotal: number; diff --git a/src/modules/cart/providers/cart.service.ts b/src/modules/cart/providers/cart.service.ts index e791c62..f67366a 100644 --- a/src/modules/cart/providers/cart.service.ts +++ b/src/modules/cart/providers/cart.service.ts @@ -28,7 +28,7 @@ export class CartService { * Set all cart parameters at once */ async setAllCartParams(userId: string, restaurantId: string, params: SetAllCartParmsDto): Promise { - const { deliveryMethodId, paymentMethodId, addressId, carAddress, description, tableNumber } = params; + const { deliveryMethodId, paymentMethodId, addressId, carAddress, description, tableNumber, attachments, paymentDesc } = params; // get existing cart (or throw) const cart = await this.findOneOrFail(userId, restaurantId); @@ -120,6 +120,14 @@ export class CartService { cart.description = description; } + if (attachments !== undefined) { + cart.attachments = attachments; + } + + if (paymentDesc !== undefined) { + cart.paymentDesc = paymentDesc; + } + // Final validation: if effective delivery method is courier, ensure all foods have pickupServe await this.validationService.assertAllFoodsHavePickupServeForCourier(cart, deliveryMethod.method); diff --git a/src/modules/orders/providers/orders.service.ts b/src/modules/orders/providers/orders.service.ts index 2e9f2eb..b347b6b 100644 --- a/src/modules/orders/providers/orders.service.ts +++ b/src/modules/orders/providers/orders.service.ts @@ -115,6 +115,8 @@ export class OrdersService { status: PaymentStatusEnum.Pending, method: order.paymentMethod.method, gateway: order.paymentMethod.gateway ?? null, + ...(cart.attachments?.length ? { attachments: cart.attachments } : {}), + ...(cart.paymentDesc ? { description: cart.paymentDesc } : {}), }); em.persist(payment); diff --git a/src/modules/payments/controllers/payments.controller.ts b/src/modules/payments/controllers/payments.controller.ts index dda4e94..250c709 100644 --- a/src/modules/payments/controllers/payments.controller.ts +++ b/src/modules/payments/controllers/payments.controller.ts @@ -131,6 +131,16 @@ export class PaymentsController { return this.paymentsService.verifyCashPayment(paymentId); } + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_PAYMENTS) + @ApiBearerAuth() + @Post('admin/payments/verify-credit-card-method/:paymentId') + @ApiOperation({ summary: 'Verify credit card payment' }) + @ApiParam({ name: 'paymentId', type: 'string', description: 'Payment ID' }) + verifyCreditCardPayment(@Param('paymentId') paymentId: string) { + return this.paymentsService.verifyCreditCardPayment(paymentId); + } + @UseGuards(AdminAuthGuard) @Permissions(Permission.VIEW_REPORTS) @ApiBearerAuth() diff --git a/src/modules/payments/entities/payment.entity.ts b/src/modules/payments/entities/payment.entity.ts index 0fa2e69..c5ba6b5 100644 --- a/src/modules/payments/entities/payment.entity.ts +++ b/src/modules/payments/entities/payment.entity.ts @@ -42,4 +42,8 @@ export class Payment extends BaseEntity { @Property({ nullable: true }) description?: string | null = null; + + // payment receipt attachments + @Property({ type: 'json', nullable: true }) + attachments?: string[]; } diff --git a/src/modules/payments/interface/payment.ts b/src/modules/payments/interface/payment.ts index 245d633..93f4b60 100644 --- a/src/modules/payments/interface/payment.ts +++ b/src/modules/payments/interface/payment.ts @@ -4,7 +4,7 @@ export enum PaymentMethodEnum { Online = 'Online', Cash = 'Cash', Wallet = 'Wallet', - Cart = 'Cart', + CreditCard = 'CreditCard', } export enum PaymentStatusEnum { Pending = 'pending', diff --git a/src/modules/payments/listeners/payment.listeners.ts b/src/modules/payments/listeners/payment.listeners.ts index a040ce4..05eb326 100644 --- a/src/modules/payments/listeners/payment.listeners.ts +++ b/src/modules/payments/listeners/payment.listeners.ts @@ -31,6 +31,7 @@ export class PaymentListeners { Cash: 'نقدی', Wallet: 'کیف پول', Online: 'آنلاین', + CreditCard: 'کارت به کارت', }; return methodMap[method] || method; } diff --git a/src/modules/payments/services/payments.service.ts b/src/modules/payments/services/payments.service.ts index 1733710..66cb2a4 100644 --- a/src/modules/payments/services/payments.service.ts +++ b/src/modules/payments/services/payments.service.ts @@ -44,6 +44,10 @@ export class PaymentsService { case PaymentMethodEnum.Online: return this.handleOnlinePayment(ctx); + case PaymentMethodEnum.CreditCard: + await this.handleCreditCardPayment(ctx); + return { paymentUrl: null }; + default: throw new BadRequestException(PaymentMessage.UNSUPPORTED_PAYMENT_METHOD); } @@ -75,6 +79,10 @@ 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); + } + return { order, amount: order.total, @@ -93,6 +101,14 @@ export class PaymentsService { }); } + private async handleCreditCardPayment(ctx: OrderPaymentContext): Promise { + await this.getOrCreateLatestPendingPayment(ctx.order.id, { + amount: ctx.amount, + method: PaymentMethodEnum.CreditCard, + gateway: null, + }); + } + // TODO clean this code and refactor it private async handleWalletPayment(ctx: OrderPaymentContext): Promise { await this.em.transactional(async em => { @@ -242,13 +258,25 @@ export class PaymentsService { } async verifyCashPayment(id: string): Promise { + return this.verifyManualPayment(id, PaymentMethodEnum.Cash, PaymentMessage.PAYMENT_METHOD_NOT_CASH); + } + + async verifyCreditCardPayment(id: string): Promise { + return this.verifyManualPayment(id, PaymentMethodEnum.CreditCard, PaymentMessage.PAYMENT_METHOD_NOT_CREDIT_CARD); + } + + private async verifyManualPayment( + id: string, + expectedMethod: PaymentMethodEnum, + wrongMethodMessage: PaymentMessage, + ): Promise { const payment = await this.em.transactional(async em => { const payment = await em.findOne(Payment, id, { populate: ['order'] }); if (!payment) { throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND); } - if (payment.method !== PaymentMethodEnum.Cash) { - throw new BadRequestException(PaymentMessage.PAYMENT_METHOD_NOT_CASH); + if (payment.method !== expectedMethod) { + throw new BadRequestException(wrongMethodMessage); } if (payment.status === PaymentStatusEnum.Paid) { throw new BadRequestException(PaymentMessage.PAYMENT_ALREADY_PAID); diff --git a/src/seeders/data/payment-methods.data.ts b/src/seeders/data/payment-methods.data.ts index cf5f610..9ccfd91 100644 --- a/src/seeders/data/payment-methods.data.ts +++ b/src/seeders/data/payment-methods.data.ts @@ -33,4 +33,12 @@ export const paymentMethodsData: PaymentMethodData[] = [ order: 3, gateway: null, }, + { + method: PaymentMethodEnum.CreditCard, + description: 'پرداخت کارت به کارت', + enabled: true, + order: 4, + gateway: null, + cardNumber: '6037991234567890', + }, ];