From 9f8d39938cf86e9040436dbf86adf4972130a26b Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sat, 4 Jul 2026 10:55:02 +0330 Subject: [PATCH] add request wallet charge --- ...60704120000_addRestaurantCreditRequests.ts | 34 ++++++++ .../controllers/restaurants.controller.ts | 11 +++ .../restaurants/dto/charge-request.dto.ts | 9 ++ .../dto/create-external-invoice.dto.ts | 79 ++++++++++++++++++ .../restaurant-credit-request.entity.ts | 20 +++++ .../interface/external-invoice.interface.ts | 17 ++++ .../restaurants/interface/plan.interface.ts | 2 +- .../restaurant-credit-request.interface.ts | 6 ++ .../providers/restaurant-wallet.service.ts | 82 ++++++++++++++++++- .../providers/restaurants.service.ts | 7 ++ src/modules/restaurants/restaurants.module.ts | 12 ++- 11 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 database/migrations/Migration20260704120000_addRestaurantCreditRequests.ts create mode 100644 src/modules/restaurants/dto/charge-request.dto.ts create mode 100755 src/modules/restaurants/dto/create-external-invoice.dto.ts create mode 100644 src/modules/restaurants/entities/restaurant-credit-request.entity.ts create mode 100644 src/modules/restaurants/interface/external-invoice.interface.ts create mode 100644 src/modules/restaurants/interface/restaurant-credit-request.interface.ts diff --git a/database/migrations/Migration20260704120000_addRestaurantCreditRequests.ts b/database/migrations/Migration20260704120000_addRestaurantCreditRequests.ts new file mode 100644 index 0000000..ac26497 --- /dev/null +++ b/database/migrations/Migration20260704120000_addRestaurantCreditRequests.ts @@ -0,0 +1,34 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260704120000_addRestaurantCreditRequests extends Migration { + + override async up(): Promise { + this.addSql(` + create table "restaurant_credit_requests" ( + "id" char(26) not null, + "created_at" timestamptz not null default now(), + "updated_at" timestamptz not null default now(), + "deleted_at" timestamptz null, + "restaurant_id" char(26) not null, + "amount" numeric(10,0) not null, + "invoice_id" varchar(255) not null, + "status" text check ("status" in ('pending', 'paid', 'cancelled', 'failed')) not null default 'pending', + constraint "restaurant_credit_requests_pkey" primary key ("id") + ); + `); + this.addSql(`create index "restaurant_credit_requests_created_at_index" on "restaurant_credit_requests" ("created_at");`); + this.addSql(`create index "restaurant_credit_requests_deleted_at_index" on "restaurant_credit_requests" ("deleted_at");`); + this.addSql(`create index "restaurant_credit_requests_restaurant_id_index" on "restaurant_credit_requests" ("restaurant_id");`); + this.addSql(` + alter table "restaurant_credit_requests" + add constraint "restaurant_credit_requests_restaurant_id_foreign" + foreign key ("restaurant_id") references "restaurants" ("id") on update cascade; + `); + } + + override async down(): Promise { + this.addSql(`alter table "restaurant_credit_requests" drop constraint if exists "restaurant_credit_requests_restaurant_id_foreign";`); + this.addSql(`drop table if exists "restaurant_credit_requests" cascade;`); + } + +} diff --git a/src/modules/restaurants/controllers/restaurants.controller.ts b/src/modules/restaurants/controllers/restaurants.controller.ts index 07ecfbb..3c67fe9 100644 --- a/src/modules/restaurants/controllers/restaurants.controller.ts +++ b/src/modules/restaurants/controllers/restaurants.controller.ts @@ -24,6 +24,7 @@ import { Permission } from 'src/common/enums/permission.enum'; import { CacheResponse } from 'src/common/decorators/cache-response.decorator'; import { CacheKeyPrefixes } from 'src/common/constants/cache-keys.constant'; import { UpdateRestaurantBgDto } from '../dto/update-restaurant-bg.dto'; +import { ChargeRequestDto } from '../dto/charge-request.dto'; @ApiTags('restaurants') @Controller() @@ -97,6 +98,16 @@ export class RestaurantsController { return this.restaurantsService.findAllBackgrounds(); } + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Permissions(Permission.UPDATE_RESTAURANT) + @ApiOperation({ summary: 'Request wallet charge via external invoice' }) + @ApiBody({ type: ChargeRequestDto }) + @Post('admin/restaurants/charge-request') + chargeRequest(@RestId() restId: string, @Body() dto: ChargeRequestDto) { + return this.restaurantsService.chargeRequest(restId, dto); + } + /** Super Admin Endpoints */ @UseGuards(SuperAdminAuthGuard) @ApiBearerAuth() diff --git a/src/modules/restaurants/dto/charge-request.dto.ts b/src/modules/restaurants/dto/charge-request.dto.ts new file mode 100644 index 0000000..893979d --- /dev/null +++ b/src/modules/restaurants/dto/charge-request.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, Min } from 'class-validator'; + +export class ChargeRequestDto { + @ApiProperty({ description: 'Charge amount in Rials', example: 1_000_000 }) + @IsInt() + @Min(1) + amount!: number; +} diff --git a/src/modules/restaurants/dto/create-external-invoice.dto.ts b/src/modules/restaurants/dto/create-external-invoice.dto.ts new file mode 100755 index 0000000..67b899b --- /dev/null +++ b/src/modules/restaurants/dto/create-external-invoice.dto.ts @@ -0,0 +1,79 @@ +import { ApiProperty, ApiPropertyOptional, OmitType } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { ArrayMinSize, IsBoolean, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Length, ValidateNested } from "class-validator"; + +export enum RecurringPeriodEnum { + WEEKLY = 1, // هفتگی + MONTHLY, // ماهانه + QUARTERLY, // سه‌ماهه + SEMIANNUALLY, // شش‌ماهه + ANNUALLY, // سالانه +} + + +export class InvoiceItemDto { + @IsNotEmpty() + @IsString() + @Length(1, 150,) + @ApiProperty() + name: string; + + @IsNotEmpty() + @IsInt() + @ApiProperty({ type: Number, description: "Item count", example: 2 }) + count: number; + + @IsNotEmpty() + @IsInt() + @ApiProperty({ description: "Item unit price", example: 100_000 }) + unitPrice: number; + + @IsOptional() + @IsNotEmpty() + @IsInt() + @ApiProperty({ description: "Item discount", example: 10 }) + discount: number; +} + +export class CreateExternalInvoiceDto { + @ApiProperty({ + type: [InvoiceItemDto], + description: "Invoice items", + example: [{ name: "item1", count: 2, unitPrice: 100_000, discount: 10 }], + }) + @ValidateNested({ each: true }) + @ArrayMinSize(1) + @Type(() => InvoiceItemDto) + items: InvoiceItemDto[]; + + @IsOptional() + @IsBoolean() + @ApiProperty({ description: "Is this a recurring invoice", example: false, default: false }) + isRecurring?: boolean; + + @IsOptional() + @IsNotEmpty() + @IsEnum(RecurringPeriodEnum) + @ApiPropertyOptional({ + description: "Recurring period (daily, weekly, monthly, quarterly, yearly)", + example: RecurringPeriodEnum.QUARTERLY, + enum: RecurringPeriodEnum, + }) + recurringPeriod?: RecurringPeriodEnum; + + @IsOptional() + @IsInt() + @ApiPropertyOptional({ description: "Maximum number of recurring cycles (0 for unlimited)", example: 12, default: 0 }) + maxRecurringCycles?: number; + + @IsNotEmpty() + @IsUUID("4",) + @ApiProperty() + danakSubscriptionId: string; + + @IsNotEmpty() + @IsString() + @ApiProperty({ description: "Business id", example: "123e4567-e89b-12d3-a456-426614174000" }) + businessId: string; +} + diff --git a/src/modules/restaurants/entities/restaurant-credit-request.entity.ts b/src/modules/restaurants/entities/restaurant-credit-request.entity.ts new file mode 100644 index 0000000..d462715 --- /dev/null +++ b/src/modules/restaurants/entities/restaurant-credit-request.entity.ts @@ -0,0 +1,20 @@ +import { Entity, Enum, Index, ManyToOne, Property } from '@mikro-orm/core'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { Restaurant } from './restaurant.entity'; +import { RestaurantCreditRequestStatus } from '../interface/restaurant-credit-request.interface'; + +@Entity({ tableName: 'restaurant_credit_requests' }) +@Index({ properties: ['restaurant'] }) +export class RestaurantCreditRequest extends BaseEntity { + @ManyToOne(() => Restaurant) + restaurant!: Restaurant; + + @Property({ type: 'decimal', precision: 10, scale: 0 }) + amount!: number; + + @Property() + invoiceId!: string; + + @Enum(() => RestaurantCreditRequestStatus) + status: RestaurantCreditRequestStatus = RestaurantCreditRequestStatus.PENDING; +} diff --git a/src/modules/restaurants/interface/external-invoice.interface.ts b/src/modules/restaurants/interface/external-invoice.interface.ts new file mode 100644 index 0000000..facb55f --- /dev/null +++ b/src/modules/restaurants/interface/external-invoice.interface.ts @@ -0,0 +1,17 @@ +import { RecurringPeriodEnum } from '../dto/create-external-invoice.dto'; + +export interface InvoiceItem { + name: string; + count: number; + unitPrice: number; + discount?: number; +} + +export interface CreateExternalInvoice { + items: InvoiceItem[]; + isRecurring?: boolean; + recurringPeriod?: RecurringPeriodEnum; + maxRecurringCycles?: number; + danakSubscriptionId: string; + businessId: string; +} diff --git a/src/modules/restaurants/interface/plan.interface.ts b/src/modules/restaurants/interface/plan.interface.ts index a5cb074..4faa3b1 100644 --- a/src/modules/restaurants/interface/plan.interface.ts +++ b/src/modules/restaurants/interface/plan.interface.ts @@ -1,4 +1,4 @@ export enum PlanEnum { Base = 'base', Premium = 'premium', -} +} \ No newline at end of file diff --git a/src/modules/restaurants/interface/restaurant-credit-request.interface.ts b/src/modules/restaurants/interface/restaurant-credit-request.interface.ts new file mode 100644 index 0000000..2e3fe08 --- /dev/null +++ b/src/modules/restaurants/interface/restaurant-credit-request.interface.ts @@ -0,0 +1,6 @@ +export enum RestaurantCreditRequestStatus { + PENDING = 'pending', + PAID = 'paid', + CANCELLED = 'cancelled', + FAILED = 'failed', +} diff --git a/src/modules/restaurants/providers/restaurant-wallet.service.ts b/src/modules/restaurants/providers/restaurant-wallet.service.ts index 1d8473e..6226fa4 100644 --- a/src/modules/restaurants/providers/restaurant-wallet.service.ts +++ b/src/modules/restaurants/providers/restaurant-wallet.service.ts @@ -1,15 +1,30 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; import { EntityManager } from '@mikro-orm/postgresql'; +import { AxiosError } from 'axios'; +import { catchError, firstValueFrom, throwError } from 'rxjs'; import { Restaurant } from '../entities/restaurant.entity'; +import { RestaurantCreditRequest } from '../entities/restaurant-credit-request.entity'; import { RestaurantCreditTransaction } from '../entities/restaurant-credit-transaction.entity'; import { RestaurantCreditTransactionReason, RestaurantCreditTransactionType, } from '../interface/restaurant-credit-transaction.interface'; +import { RestaurantCreditRequestStatus } from '../interface/restaurant-credit-request.interface'; +import { CreateExternalInvoice } from '../interface/external-invoice.interface'; +import { ChargeRequestDto } from '../dto/charge-request.dto'; +import { RestMessage } from 'src/common/enums/message.enum'; @Injectable() export class RestaurantWalletService { - constructor(private readonly defaultEm: EntityManager) {} + private readonly logger = new Logger(RestaurantWalletService.name); + + constructor( + private readonly defaultEm: EntityManager, + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) {} async createTransaction(params: { restaurant: Restaurant; @@ -46,4 +61,67 @@ export class RestaurantWalletService { await em.persistAndFlush([restaurant, creditTransaction]); } + async chargeRequest(restId: string, dto: ChargeRequestDto): Promise { + const restaurant = await this.defaultEm.findOne(Restaurant, { id: restId }); + if (!restaurant) { + throw new NotFoundException(RestMessage.NOT_FOUND); + } + + if (!restaurant.subscriptionId) { + throw new BadRequestException('اشتراک رستوران یافت نشد'); + } + + const params: CreateExternalInvoice = { + danakSubscriptionId: restaurant.subscriptionId, + businessId: restId, + items: [ + { + name: 'dmenu_wallet_charge', + count: 1, + unitPrice: dto.amount, + discount: 0, + }, + ], + }; + + const danakApiUrl = this.configService.getOrThrow('DANAK_API_URL'); + const xApiKey = this.configService.getOrThrow('EXTERNAL_API_KEYS'); + + let invoiceId: string; + + try { + const { data } = await firstValueFrom( + this.httpService + .post<{ data: { invoice: { id: string } } }>(`${danakApiUrl}/invoices/external/create`, params, { + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': xApiKey, + }, + }) + .pipe( + catchError((err: AxiosError) => { + this.logger.error(`Failed to create invoice: ${err.message}`, err.stack); + return throwError(() => err); + }), + ), + ); + invoiceId = data.data.invoice.id; + } catch (error: unknown) { + this.logger.error( + `Error creating invoice: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + throw new BadRequestException('ایجاد فاکتور با خطا مواجه شد'); + } + + const creditRequest = this.defaultEm.create(RestaurantCreditRequest, { + restaurant, + amount: dto.amount, + invoiceId, + status: RestaurantCreditRequestStatus.PENDING, + }); + + await this.defaultEm.persistAndFlush(creditRequest); + + return creditRequest; + } } diff --git a/src/modules/restaurants/providers/restaurants.service.ts b/src/modules/restaurants/providers/restaurants.service.ts index 9e2acc8..f49da7d 100644 --- a/src/modules/restaurants/providers/restaurants.service.ts +++ b/src/modules/restaurants/providers/restaurants.service.ts @@ -32,6 +32,8 @@ import { CacheService } from 'src/modules/utils/cache.service'; import { CacheKeys } from 'src/common/constants/cache-keys.constant'; import { BackgroundRepository } from '../repositories/background.repository'; import { ActiveRestaurantListItemDto } from '../dto/active-restaurant-list-item.dto'; +import { RestaurantWalletService } from './restaurant-wallet.service'; +import { ChargeRequestDto } from '../dto/charge-request.dto'; @Injectable() @@ -41,6 +43,7 @@ export class RestaurantsService { private readonly restRepository: RestRepository, private readonly backgroundRepository: BackgroundRepository, private readonly cacheService: CacheService, + private readonly restaurantWalletService: RestaurantWalletService, ) { } async setupRestuarant(dto: SetupRestaurantDto): Promise { @@ -326,6 +329,10 @@ export class RestaurantsService { return this.backgroundRepository.findAll(); } + chargeRequest(restId: string, dto: ChargeRequestDto) { + return this.restaurantWalletService.chargeRequest(restId, dto); + } + async updateBackground(id: string, dto: UpdateRestaurantBgDto): Promise { const restaurant = await this.restRepository.findOne({ id }); diff --git a/src/modules/restaurants/restaurants.module.ts b/src/modules/restaurants/restaurants.module.ts index a4dc93f..3aac060 100644 --- a/src/modules/restaurants/restaurants.module.ts +++ b/src/modules/restaurants/restaurants.module.ts @@ -1,4 +1,5 @@ import { Module, forwardRef } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; import { RestaurantsService } from './providers/restaurants.service'; import { RestaurantsController } from './controllers/restaurants.controller'; import { MikroOrmModule } from '@mikro-orm/nestjs'; @@ -15,7 +16,9 @@ import { UtilsModule } from '../utils/utils.module'; import { Background } from './entities/background.entity'; import { BackgroundRepository } from './repositories/background.repository'; import { RestaurantCreditTransaction } from './entities/restaurant-credit-transaction.entity'; +import { RestaurantCreditRequest } from './entities/restaurant-credit-request.entity'; import { RestaurantWalletService } from './providers/restaurant-wallet.service'; +import { httpConfig } from 'src/config/http.config'; @Module({ controllers: [RestaurantsController, ScheduleController], @@ -28,8 +31,13 @@ import { RestaurantWalletService } from './providers/restaurant-wallet.service'; BackgroundRepository, RestaurantWalletService, ], - imports: [MikroOrmModule.forFeature([Restaurant, Schedule, Background, RestaurantCreditTransaction]), - JwtModule, forwardRef(() => AuthModule), UtilsModule], + imports: [ + MikroOrmModule.forFeature([Restaurant, Schedule, Background, RestaurantCreditTransaction, RestaurantCreditRequest]), + HttpModule.registerAsync(httpConfig()), + JwtModule, + forwardRef(() => AuthModule), + UtilsModule, + ], exports: [RestRepository, ScheduleRepository, ScheduleService, RestaurantsService, RestaurantWalletService], }) export class RestaurantsModule { }