add request wallet charge
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20260704120000_addRestaurantCreditRequests extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
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<void> {
|
||||
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;`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export enum PlanEnum {
|
||||
Base = 'base',
|
||||
Premium = 'premium',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum RestaurantCreditRequestStatus {
|
||||
PENDING = 'pending',
|
||||
PAID = 'paid',
|
||||
CANCELLED = 'cancelled',
|
||||
FAILED = 'failed',
|
||||
}
|
||||
@@ -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<RestaurantCreditRequest> {
|
||||
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<string>('DANAK_API_URL');
|
||||
const xApiKey = this.configService.getOrThrow<string>('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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Restaurant> {
|
||||
@@ -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<Restaurant> {
|
||||
const restaurant = await this.restRepository.findOne({ id });
|
||||
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
Reference in New Issue
Block a user