feat: billing system — service plans, wallet, cost calculation
- Backend: billing module with ServicePlan, PricingRule, Wallet, WalletTransaction entities - Admin can CRUD service plans with hourly/monthly/yearly billing cycles - Each plan has flexible pricing rules (base_fee, cpu, memory, storage, db addon) - Wallet system: auto-created per user, charge, deduct, refund, transaction history - Cost calculation endpoint: cross-cycle conversion (hourly*720=monthly, monthly*12=yearly) - Frontend: admin billing management page (/dashboard/admin/billing) - Frontend: user wallet page with balance, quick-charge, transaction history - Deploy page: cost breakdown shown in Review step (hourly/monthly/yearly) - Navigation: Wallet link for users, Billing Plans link for admins
This commit is contained in:
@@ -10,6 +10,7 @@ import { ClustersModule } from './clusters/clusters.module';
|
|||||||
import { KubernetesModule } from './kubernetes/kubernetes.module';
|
import { KubernetesModule } from './kubernetes/kubernetes.module';
|
||||||
import { BuildModule } from './build/build.module';
|
import { BuildModule } from './build/build.module';
|
||||||
import { TicketsModule } from './tickets/tickets.module';
|
import { TicketsModule } from './tickets/tickets.module';
|
||||||
|
import { BillingModule } from './billing/billing.module';
|
||||||
import configuration from './config/configuration';
|
import configuration from './config/configuration';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -58,6 +59,7 @@ import configuration from './config/configuration';
|
|||||||
KubernetesModule,
|
KubernetesModule,
|
||||||
BuildModule,
|
BuildModule,
|
||||||
TicketsModule,
|
TicketsModule,
|
||||||
|
BillingModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { BillingService } from './billing.service';
|
||||||
|
import {
|
||||||
|
CreateServicePlanDto,
|
||||||
|
UpdateServicePlanDto,
|
||||||
|
ChargeWalletDto,
|
||||||
|
CalculateCostDto,
|
||||||
|
} from './dto/billing.dto';
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
import { UserRole } from '../common/enums';
|
||||||
|
|
||||||
|
@ApiTags('Billing')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('billing')
|
||||||
|
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||||
|
export class BillingController {
|
||||||
|
constructor(private readonly billingService: BillingService) {}
|
||||||
|
|
||||||
|
// ─── Service Plans (Admin) ────────────────────────────────────────
|
||||||
|
|
||||||
|
@Post('plans')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Create a new service plan (Admin)' })
|
||||||
|
async createPlan(@Body() dto: CreateServicePlanDto) {
|
||||||
|
return this.billingService.createPlan(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('plans')
|
||||||
|
@ApiOperation({ summary: 'List all service plans' })
|
||||||
|
async findAllPlans(@Request() req: any) {
|
||||||
|
if (req.user.role === UserRole.ADMIN) {
|
||||||
|
return this.billingService.findAllPlans();
|
||||||
|
}
|
||||||
|
return this.billingService.findActivePlans();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('plans/:id')
|
||||||
|
@ApiOperation({ summary: 'Get service plan details' })
|
||||||
|
async findPlan(@Param('id') id: string) {
|
||||||
|
return this.billingService.findPlan(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('plans/:id')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Update a service plan (Admin)' })
|
||||||
|
async updatePlan(@Param('id') id: string, @Body() dto: UpdateServicePlanDto) {
|
||||||
|
return this.billingService.updatePlan(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('plans/:id')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Delete a service plan (Admin)' })
|
||||||
|
async deletePlan(@Param('id') id: string) {
|
||||||
|
await this.billingService.deletePlan(id);
|
||||||
|
return { message: 'Plan deleted' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cost Calculation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Post('calculate')
|
||||||
|
@ApiOperation({ summary: 'Calculate cost for an application configuration' })
|
||||||
|
async calculateCost(@Body() dto: CalculateCostDto) {
|
||||||
|
return this.billingService.calculateCost(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wallet (User) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Get('wallet')
|
||||||
|
@ApiOperation({ summary: 'Get my wallet balance' })
|
||||||
|
async getBalance(@Request() req: any) {
|
||||||
|
return this.billingService.getBalance(req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('wallet/charge')
|
||||||
|
@ApiOperation({ summary: 'Charge my wallet (self top-up)' })
|
||||||
|
async chargeMyWallet(@Request() req: any, @Body() dto: ChargeWalletDto) {
|
||||||
|
return this.billingService.chargeWallet(req.user.id, dto.amount, dto.description || 'Self top-up');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('wallet/transactions')
|
||||||
|
@ApiOperation({ summary: 'Get my wallet transactions' })
|
||||||
|
async getTransactions(@Request() req: any, @Query('limit') limit?: string) {
|
||||||
|
return this.billingService.getTransactions(req.user.id, limit ? parseInt(limit, 10) : 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('wallet/pay/:applicationId')
|
||||||
|
@ApiOperation({ summary: 'Pay for an application from wallet' })
|
||||||
|
async payForApplication(
|
||||||
|
@Request() req: any,
|
||||||
|
@Param('applicationId') applicationId: string,
|
||||||
|
@Body() body: { amount: number; cycle: string },
|
||||||
|
) {
|
||||||
|
return this.billingService.deductWallet(
|
||||||
|
req.user.id,
|
||||||
|
body.amount,
|
||||||
|
`Payment for application (${body.cycle})`,
|
||||||
|
applicationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wallet Admin ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Get('admin/wallets')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'List all wallets (Admin)' })
|
||||||
|
async getAllWallets() {
|
||||||
|
return this.billingService.getAllWallets();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('admin/wallets/:userId/charge')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Charge a user\'s wallet (Admin)' })
|
||||||
|
async adminChargeWallet(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Body() dto: ChargeWalletDto,
|
||||||
|
) {
|
||||||
|
return this.billingService.adminChargeWallet(userId, dto.amount, dto.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BillingService } from './billing.service';
|
||||||
|
import { BillingController } from './billing.controller';
|
||||||
|
import { ServicePlan } from './entities/service-plan.entity';
|
||||||
|
import { PricingRule } from './entities/pricing-rule.entity';
|
||||||
|
import { Wallet } from './entities/wallet.entity';
|
||||||
|
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ServicePlan, PricingRule, Wallet, WalletTransaction]),
|
||||||
|
],
|
||||||
|
controllers: [BillingController],
|
||||||
|
providers: [BillingService],
|
||||||
|
exports: [BillingService],
|
||||||
|
})
|
||||||
|
export class BillingModule {}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ServicePlan } from './entities/service-plan.entity';
|
||||||
|
import { PricingRule } from './entities/pricing-rule.entity';
|
||||||
|
import { Wallet } from './entities/wallet.entity';
|
||||||
|
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||||
|
import { TransactionType, BillingCycle, PricingResourceType } from '../common/enums';
|
||||||
|
import {
|
||||||
|
CreateServicePlanDto,
|
||||||
|
UpdateServicePlanDto,
|
||||||
|
CalculateCostDto,
|
||||||
|
} from './dto/billing.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BillingService {
|
||||||
|
private readonly logger = new Logger(BillingService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ServicePlan) private planRepo: Repository<ServicePlan>,
|
||||||
|
@InjectRepository(PricingRule) private ruleRepo: Repository<PricingRule>,
|
||||||
|
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
|
||||||
|
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ─── Service Plans ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async createPlan(dto: CreateServicePlanDto): Promise<ServicePlan> {
|
||||||
|
const plan = this.planRepo.create({
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
billingCycle: dto.billingCycle,
|
||||||
|
});
|
||||||
|
const saved = await this.planRepo.save(plan);
|
||||||
|
|
||||||
|
// Create pricing rules
|
||||||
|
const rules = dto.pricingRules.map((r) =>
|
||||||
|
this.ruleRepo.create({ ...r, planId: saved.id }),
|
||||||
|
);
|
||||||
|
await this.ruleRepo.save(rules);
|
||||||
|
|
||||||
|
return this.planRepo.findOne({ where: { id: saved.id }, relations: ['pricingRules'] }) as Promise<ServicePlan>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePlan(id: string, dto: UpdateServicePlanDto): Promise<ServicePlan> {
|
||||||
|
const plan = await this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] });
|
||||||
|
if (!plan) throw new NotFoundException('Plan not found');
|
||||||
|
|
||||||
|
if (dto.name !== undefined) plan.name = dto.name;
|
||||||
|
if (dto.description !== undefined) plan.description = dto.description;
|
||||||
|
if (dto.billingCycle !== undefined) plan.billingCycle = dto.billingCycle;
|
||||||
|
if (dto.isActive !== undefined) plan.isActive = dto.isActive;
|
||||||
|
|
||||||
|
await this.planRepo.save(plan);
|
||||||
|
|
||||||
|
// If pricing rules are provided, replace them
|
||||||
|
if (dto.pricingRules) {
|
||||||
|
await this.ruleRepo.delete({ planId: id });
|
||||||
|
const rules = dto.pricingRules.map((r) =>
|
||||||
|
this.ruleRepo.create({ ...r, planId: id }),
|
||||||
|
);
|
||||||
|
await this.ruleRepo.save(rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] }) as Promise<ServicePlan>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePlan(id: string): Promise<void> {
|
||||||
|
const plan = await this.planRepo.findOne({ where: { id } });
|
||||||
|
if (!plan) throw new NotFoundException('Plan not found');
|
||||||
|
await this.planRepo.remove(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllPlans(): Promise<ServicePlan[]> {
|
||||||
|
return this.planRepo.find({ relations: ['pricingRules'], order: { createdAt: 'DESC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActivePlans(): Promise<ServicePlan[]> {
|
||||||
|
return this.planRepo.find({
|
||||||
|
where: { isActive: true },
|
||||||
|
relations: ['pricingRules'],
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPlan(id: string): Promise<ServicePlan> {
|
||||||
|
const plan = await this.planRepo.findOne({ where: { id }, relations: ['pricingRules'] });
|
||||||
|
if (!plan) throw new NotFoundException('Plan not found');
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cost Calculation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate cost for an application config against all active plans.
|
||||||
|
* Returns breakdown for each billing cycle (hourly, monthly, yearly).
|
||||||
|
*/
|
||||||
|
async calculateCost(dto: CalculateCostDto): Promise<{
|
||||||
|
hourly: number;
|
||||||
|
monthly: number;
|
||||||
|
yearly: number;
|
||||||
|
breakdown: { label: string; hourly: number; monthly: number; yearly: number }[];
|
||||||
|
}> {
|
||||||
|
const plans = await this.findActivePlans();
|
||||||
|
if (plans.length === 0) {
|
||||||
|
return { hourly: 0, monthly: 0, yearly: 0, breakdown: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather all active pricing rules across all plans, grouped by cycle
|
||||||
|
const hourlyRules: PricingRule[] = [];
|
||||||
|
const monthlyRules: PricingRule[] = [];
|
||||||
|
const yearlyRules: PricingRule[] = [];
|
||||||
|
|
||||||
|
for (const plan of plans) {
|
||||||
|
switch (plan.billingCycle) {
|
||||||
|
case BillingCycle.HOURLY: hourlyRules.push(...plan.pricingRules); break;
|
||||||
|
case BillingCycle.MONTHLY: monthlyRules.push(...plan.pricingRules); break;
|
||||||
|
case BillingCycle.YEARLY: yearlyRules.push(...plan.pricingRules); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first available set of rules, or compute cross-conversions
|
||||||
|
const rules = hourlyRules.length > 0 ? hourlyRules : monthlyRules.length > 0 ? monthlyRules : yearlyRules;
|
||||||
|
const baseCycle = hourlyRules.length > 0 ? 'hourly' : monthlyRules.length > 0 ? 'monthly' : 'yearly';
|
||||||
|
|
||||||
|
// Parse resource values
|
||||||
|
const cpuCores = this.parseCpuToCores(dto.cpuLimit);
|
||||||
|
const memoryGb = this.parseMemoryToGb(dto.memoryLimit);
|
||||||
|
const storageGb = dto.dbStorageSize ? parseFloat(dto.dbStorageSize.replace('Gi', '')) || 0 : 0;
|
||||||
|
const hasDatabase = dto.databaseType !== 'none';
|
||||||
|
const replicas = dto.replicas || 1;
|
||||||
|
|
||||||
|
const breakdown: { label: string; hourly: number; monthly: number; yearly: number }[] = [];
|
||||||
|
let totalBase = 0;
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
let cost = 0;
|
||||||
|
let label = '';
|
||||||
|
|
||||||
|
switch (rule.resourceType) {
|
||||||
|
case PricingResourceType.BASE_FEE:
|
||||||
|
cost = Number(rule.unitPrice);
|
||||||
|
label = 'هزینه پایه';
|
||||||
|
break;
|
||||||
|
case PricingResourceType.CPU_PER_CORE:
|
||||||
|
cost = cpuCores * replicas * Number(rule.unitPrice);
|
||||||
|
label = `CPU (${(cpuCores * replicas).toFixed(2)} core)`;
|
||||||
|
break;
|
||||||
|
case PricingResourceType.MEMORY_PER_GB:
|
||||||
|
cost = memoryGb * replicas * Number(rule.unitPrice);
|
||||||
|
label = `Memory (${(memoryGb * replicas).toFixed(2)} GB)`;
|
||||||
|
break;
|
||||||
|
case PricingResourceType.STORAGE_PER_GB:
|
||||||
|
cost = storageGb * Number(rule.unitPrice);
|
||||||
|
label = `Storage (${storageGb} GB)`;
|
||||||
|
break;
|
||||||
|
case PricingResourceType.DATABASE_ADDON:
|
||||||
|
cost = hasDatabase ? Number(rule.unitPrice) : 0;
|
||||||
|
label = 'Database addon';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cost > 0) {
|
||||||
|
const hourly = baseCycle === 'hourly' ? cost : baseCycle === 'monthly' ? cost / 720 : cost / 8640;
|
||||||
|
const monthly = baseCycle === 'monthly' ? cost : baseCycle === 'hourly' ? cost * 720 : cost / 12;
|
||||||
|
const yearly = baseCycle === 'yearly' ? cost : baseCycle === 'monthly' ? cost * 12 : cost * 8640;
|
||||||
|
|
||||||
|
breakdown.push({ label, hourly: Math.round(hourly), monthly: Math.round(monthly), yearly: Math.round(yearly) });
|
||||||
|
totalBase += cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hourlyTotal = baseCycle === 'hourly' ? totalBase : baseCycle === 'monthly' ? totalBase / 720 : totalBase / 8640;
|
||||||
|
const monthlyTotal = baseCycle === 'monthly' ? totalBase : baseCycle === 'hourly' ? totalBase * 720 : totalBase / 12;
|
||||||
|
const yearlyTotal = baseCycle === 'yearly' ? totalBase : baseCycle === 'monthly' ? totalBase * 12 : totalBase * 8640;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hourly: Math.round(hourlyTotal),
|
||||||
|
monthly: Math.round(monthlyTotal),
|
||||||
|
yearly: Math.round(yearlyTotal),
|
||||||
|
breakdown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCpuToCores(cpu: string): number {
|
||||||
|
if (!cpu) return 0;
|
||||||
|
if (cpu.endsWith('m')) return parseFloat(cpu) / 1000;
|
||||||
|
return parseFloat(cpu) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseMemoryToGb(memory: string): number {
|
||||||
|
if (!memory) return 0;
|
||||||
|
if (memory.endsWith('Gi')) return parseFloat(memory);
|
||||||
|
if (memory.endsWith('Mi')) return parseFloat(memory) / 1024;
|
||||||
|
if (memory.endsWith('Ki')) return parseFloat(memory) / (1024 * 1024);
|
||||||
|
return parseFloat(memory) / (1024 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wallet ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getOrCreateWallet(userId: string): Promise<Wallet> {
|
||||||
|
let wallet = await this.walletRepo.findOne({ where: { userId } });
|
||||||
|
if (!wallet) {
|
||||||
|
wallet = this.walletRepo.create({ userId, balance: 0 });
|
||||||
|
wallet = await this.walletRepo.save(wallet);
|
||||||
|
this.logger.log(`Created wallet for user ${userId}`);
|
||||||
|
}
|
||||||
|
return wallet;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalance(userId: string): Promise<{ balance: number }> {
|
||||||
|
const wallet = await this.getOrCreateWallet(userId);
|
||||||
|
return { balance: Number(wallet.balance) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async chargeWallet(userId: string, amount: number, description?: string): Promise<WalletTransaction> {
|
||||||
|
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
||||||
|
|
||||||
|
const wallet = await this.getOrCreateWallet(userId);
|
||||||
|
wallet.balance = Number(wallet.balance) + amount;
|
||||||
|
await this.walletRepo.save(wallet);
|
||||||
|
|
||||||
|
const tx = this.txRepo.create({
|
||||||
|
walletId: wallet.id,
|
||||||
|
type: TransactionType.CHARGE,
|
||||||
|
amount,
|
||||||
|
balanceAfter: wallet.balance,
|
||||||
|
description: description || 'Wallet charge',
|
||||||
|
});
|
||||||
|
const saved = await this.txRepo.save(tx);
|
||||||
|
|
||||||
|
this.logger.log(`Charged wallet of user ${userId}: +${amount} Toman → balance: ${wallet.balance}`);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deductWallet(
|
||||||
|
userId: string,
|
||||||
|
amount: number,
|
||||||
|
description?: string,
|
||||||
|
applicationId?: string,
|
||||||
|
): Promise<WalletTransaction> {
|
||||||
|
if (amount <= 0) throw new BadRequestException('Amount must be positive');
|
||||||
|
|
||||||
|
const wallet = await this.getOrCreateWallet(userId);
|
||||||
|
if (Number(wallet.balance) < amount) {
|
||||||
|
throw new BadRequestException('Insufficient wallet balance');
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet.balance = Number(wallet.balance) - amount;
|
||||||
|
await this.walletRepo.save(wallet);
|
||||||
|
|
||||||
|
const tx = this.txRepo.create({
|
||||||
|
walletId: wallet.id,
|
||||||
|
type: TransactionType.DEDUCTION,
|
||||||
|
amount,
|
||||||
|
balanceAfter: wallet.balance,
|
||||||
|
description: description || 'Service payment',
|
||||||
|
applicationId,
|
||||||
|
});
|
||||||
|
const saved = await this.txRepo.save(tx);
|
||||||
|
|
||||||
|
this.logger.log(`Deducted from wallet of user ${userId}: -${amount} Toman → balance: ${wallet.balance}`);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTransactions(userId: string, limit = 50): Promise<WalletTransaction[]> {
|
||||||
|
const wallet = await this.getOrCreateWallet(userId);
|
||||||
|
return this.txRepo.find({
|
||||||
|
where: { walletId: wallet.id },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin: charge any user's wallet
|
||||||
|
async adminChargeWallet(userId: string, amount: number, description?: string): Promise<WalletTransaction> {
|
||||||
|
return this.chargeWallet(userId, amount, description || 'Admin charge');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin: get all wallets
|
||||||
|
async getAllWallets(): Promise<Wallet[]> {
|
||||||
|
return this.walletRepo.find({ relations: ['user'], order: { balance: 'DESC' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { IsString, IsEnum, IsOptional, IsBoolean, IsNumber, IsArray, ValidateNested, Min } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { BillingCycle, PricingResourceType } from '../../common/enums';
|
||||||
|
|
||||||
|
export class CreatePricingRuleDto {
|
||||||
|
@ApiProperty({ enum: PricingResourceType })
|
||||||
|
@IsEnum(PricingResourceType)
|
||||||
|
resourceType: PricingResourceType;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 5000, description: 'Unit price in Toman' })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
unitPrice: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'CPU per core per hour' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateServicePlanDto {
|
||||||
|
@ApiProperty({ example: 'Node.js Basic' })
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Basic plan for Node.js applications' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: BillingCycle })
|
||||||
|
@IsEnum(BillingCycle)
|
||||||
|
billingCycle: BillingCycle;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [CreatePricingRuleDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreatePricingRuleDto)
|
||||||
|
pricingRules: CreatePricingRuleDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateServicePlanDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: BillingCycle })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(BillingCycle)
|
||||||
|
billingCycle?: BillingCycle;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [CreatePricingRuleDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreatePricingRuleDto)
|
||||||
|
pricingRules?: CreatePricingRuleDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChargeWalletDto {
|
||||||
|
@ApiProperty({ example: 50000, description: 'Amount to charge in Toman' })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1000)
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Manual top-up' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CalculateCostDto {
|
||||||
|
@ApiProperty({ example: 'nodejs' })
|
||||||
|
@IsString()
|
||||||
|
runtime: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'postgresql' })
|
||||||
|
@IsString()
|
||||||
|
databaseType: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '500m', description: 'CPU limit' })
|
||||||
|
@IsString()
|
||||||
|
cpuLimit: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '512Mi', description: 'Memory limit' })
|
||||||
|
@IsString()
|
||||||
|
memoryLimit: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1, description: 'Number of replicas' })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
replicas: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '1Gi', description: 'Database storage size' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
dbStorageSize?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { PricingResourceType } from '../../common/enums';
|
||||||
|
import { ServicePlan } from './service-plan.entity';
|
||||||
|
|
||||||
|
@Entity('pricing_rules')
|
||||||
|
export class PricingRule {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'enum', enum: PricingResourceType })
|
||||||
|
resourceType: PricingResourceType;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||||||
|
unitPrice: number; // Price per unit (Toman)
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
description: string; // e.g. "CPU per core per hour"
|
||||||
|
|
||||||
|
@ManyToOne(() => ServicePlan, (plan: ServicePlan) => plan.pricingRules, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'planId' })
|
||||||
|
plan: ServicePlan;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
planId: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { BillingCycle } from '../../common/enums';
|
||||||
|
import { PricingRule } from './pricing-rule.entity';
|
||||||
|
|
||||||
|
@Entity('service_plans')
|
||||||
|
export class ServicePlan {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name: string; // e.g. "Node.js Basic", "WordPress Pro"
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Column({ type: 'enum', enum: BillingCycle })
|
||||||
|
billingCycle: BillingCycle;
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@OneToMany(() => PricingRule, (rule) => rule.plan, { cascade: true, eager: true })
|
||||||
|
pricingRules: PricingRule[];
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { TransactionType } from '../../common/enums';
|
||||||
|
import { Wallet } from './wallet.entity';
|
||||||
|
|
||||||
|
@Entity('wallet_transactions')
|
||||||
|
export class WalletTransaction {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'enum', enum: TransactionType })
|
||||||
|
type: TransactionType;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 14, scale: 2 })
|
||||||
|
amount: number; // Amount in Toman (positive)
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 14, scale: 2 })
|
||||||
|
balanceAfter: number; // Balance after this transaction
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
description: string; // e.g. "Charge via admin", "Payment for app: my-app (monthly)"
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
applicationId: string; // Linked application (for deductions)
|
||||||
|
|
||||||
|
@ManyToOne(() => Wallet, (wallet: Wallet) => wallet.transactions, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'walletId' })
|
||||||
|
wallet: Wallet;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
walletId: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToOne,
|
||||||
|
JoinColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from '../../users/entities/user.entity';
|
||||||
|
import { WalletTransaction } from './wallet-transaction.entity';
|
||||||
|
|
||||||
|
@Entity('wallets')
|
||||||
|
export class Wallet {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 14, scale: 2, default: 0 })
|
||||||
|
balance: number; // Balance in Toman
|
||||||
|
|
||||||
|
@OneToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'userId' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@OneToMany(() => WalletTransaction, (tx: WalletTransaction) => tx.wallet)
|
||||||
|
transactions: WalletTransaction[];
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -60,3 +60,23 @@ export enum BuildStatus {
|
|||||||
SUCCESS = 'success',
|
SUCCESS = 'success',
|
||||||
FAILED = 'failed',
|
FAILED = 'failed',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum BillingCycle {
|
||||||
|
HOURLY = 'hourly',
|
||||||
|
MONTHLY = 'monthly',
|
||||||
|
YEARLY = 'yearly',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PricingResourceType {
|
||||||
|
BASE_FEE = 'base_fee', // Base fee per runtime type
|
||||||
|
CPU_PER_CORE = 'cpu_per_core', // Price per CPU core
|
||||||
|
MEMORY_PER_GB = 'memory_per_gb', // Price per GB RAM
|
||||||
|
STORAGE_PER_GB = 'storage_per_gb', // Price per GB disk
|
||||||
|
DATABASE_ADDON = 'database_addon', // Price for database addon
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TransactionType {
|
||||||
|
CHARGE = 'charge', // Top-up / deposit
|
||||||
|
DEDUCTION = 'deduction', // Payment for service
|
||||||
|
REFUND = 'refund', // Refund
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import type { ServicePlan, BillingCycle, PricingResourceType } from '@/types';
|
||||||
|
import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
|
const cycleLabels: Record<BillingCycle, string> = {
|
||||||
|
hourly: 'ساعتی',
|
||||||
|
monthly: 'ماهانه',
|
||||||
|
yearly: 'سالانه',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resourceLabels: Record<PricingResourceType, string> = {
|
||||||
|
base_fee: 'هزینه پایه',
|
||||||
|
cpu_per_core: 'CPU (هر هسته)',
|
||||||
|
memory_per_gb: 'حافظه (هر GB)',
|
||||||
|
storage_per_gb: 'دیسک (هر GB)',
|
||||||
|
database_addon: 'افزونه دیتابیس',
|
||||||
|
};
|
||||||
|
|
||||||
|
const allResourceTypes: PricingResourceType[] = ['base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon'];
|
||||||
|
|
||||||
|
interface RuleForm {
|
||||||
|
resourceType: PricingResourceType;
|
||||||
|
unitPrice: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyRule = (): RuleForm => ({ resourceType: 'base_fee', unitPrice: '', description: '' });
|
||||||
|
|
||||||
|
export default function AdminBillingPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [expandedPlan, setExpandedPlan] = useState<string | null>(null);
|
||||||
|
const [formName, setFormName] = useState('');
|
||||||
|
const [formDesc, setFormDesc] = useState('');
|
||||||
|
const [formCycle, setFormCycle] = useState<BillingCycle>('monthly');
|
||||||
|
const [rules, setRules] = useState<RuleForm[]>([emptyRule()]);
|
||||||
|
|
||||||
|
const { data: plans = [], isLoading } = useQuery<ServicePlan[]>({
|
||||||
|
queryKey: ['billing-plans'],
|
||||||
|
queryFn: () => api.get('/billing/plans').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => editingId
|
||||||
|
? api.patch(`/billing/plans/${editingId}`, data)
|
||||||
|
: api.post('/billing/plans', data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['billing-plans'] });
|
||||||
|
toast.success(editingId ? 'پلن بروزرسانی شد' : 'پلن ایجاد شد');
|
||||||
|
resetForm();
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/billing/plans/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['billing-plans'] });
|
||||||
|
toast.success('پلن حذف شد');
|
||||||
|
},
|
||||||
|
onError: () => toast.error('خطا در حذف پلن'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
|
||||||
|
api.patch(`/billing/plans/${id}`, { isActive }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['billing-plans'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setFormName('');
|
||||||
|
setFormDesc('');
|
||||||
|
setFormCycle('monthly');
|
||||||
|
setRules([emptyRule()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (plan: ServicePlan) => {
|
||||||
|
setEditingId(plan.id);
|
||||||
|
setFormName(plan.name);
|
||||||
|
setFormDesc(plan.description || '');
|
||||||
|
setFormCycle(plan.billingCycle);
|
||||||
|
setRules(
|
||||||
|
plan.pricingRules.map((r) => ({
|
||||||
|
resourceType: r.resourceType,
|
||||||
|
unitPrice: String(r.unitPrice),
|
||||||
|
description: r.description || '',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!formName.trim()) return toast.error('نام پلن الزامی است');
|
||||||
|
const validRules = rules.filter((r) => r.unitPrice && Number(r.unitPrice) > 0);
|
||||||
|
if (validRules.length === 0) return toast.error('حداقل یک قاعده قیمتگذاری اضافه کنید');
|
||||||
|
|
||||||
|
createMutation.mutate({
|
||||||
|
name: formName,
|
||||||
|
description: formDesc || undefined,
|
||||||
|
billingCycle: formCycle,
|
||||||
|
pricingRules: validRules.map((r) => ({
|
||||||
|
resourceType: r.resourceType,
|
||||||
|
unitPrice: Number(r.unitPrice),
|
||||||
|
description: r.description || undefined,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRule = () => setRules([...rules, emptyRule()]);
|
||||||
|
const removeRule = (i: number) => setRules(rules.filter((_, idx) => idx !== i));
|
||||||
|
const updateRule = (i: number, field: keyof RuleForm, value: string) => {
|
||||||
|
const updated = [...rules];
|
||||||
|
updated[i] = { ...updated[i], [field]: value };
|
||||||
|
setRules(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (n: number) => Number(n).toLocaleString('fa-IR');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title flex items-center gap-2"><DollarSign className="w-6 h-6" /> مدیریت پلنها و قیمتگذاری</h1>
|
||||||
|
<p className="page-subtitle">تعریف سرویسها و هزینهها برای هر نوع اپلیکیشن</p>
|
||||||
|
</div>
|
||||||
|
{!showForm && (
|
||||||
|
<button onClick={() => setShowForm(true)} className="btn-primary flex items-center gap-2">
|
||||||
|
<Plus className="w-4 h-4" /> پلن جدید
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create / Edit Form */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="card space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">{editingId ? 'ویرایش پلن' : 'ایجاد پلن جدید'}</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">نام پلن</label>
|
||||||
|
<input className="input-field" placeholder="مثال: Node.js پایه" value={formName} onChange={(e) => setFormName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">دوره پرداخت</label>
|
||||||
|
<select className="input-field" value={formCycle} onChange={(e) => setFormCycle(e.target.value as BillingCycle)}>
|
||||||
|
<option value="hourly">ساعتی</option>
|
||||||
|
<option value="monthly">ماهانه</option>
|
||||||
|
<option value="yearly">سالانه</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">توضیحات (اختیاری)</label>
|
||||||
|
<input className="input-field" placeholder="توضیحات درباره پلن" value={formDesc} onChange={(e) => setFormDesc(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing Rules */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-sm font-semibold text-gray-700">قواعد قیمتگذاری</label>
|
||||||
|
<button onClick={addRule} className="btn-secondary text-xs flex items-center gap-1">
|
||||||
|
<Plus className="w-3 h-3" /> افزودن
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rules.map((rule, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<select
|
||||||
|
className="input-field flex-1 text-sm"
|
||||||
|
value={rule.resourceType}
|
||||||
|
onChange={(e) => updateRule(i, 'resourceType', e.target.value)}
|
||||||
|
>
|
||||||
|
{allResourceTypes.map((rt) => (
|
||||||
|
<option key={rt} value={rt}>{resourceLabels[rt]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="input-field w-32 text-sm"
|
||||||
|
type="number"
|
||||||
|
placeholder="قیمت (تومان)"
|
||||||
|
value={rule.unitPrice}
|
||||||
|
onChange={(e) => updateRule(i, 'unitPrice', e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input-field flex-1 text-sm"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={rule.description}
|
||||||
|
onChange={(e) => updateRule(i, 'description', e.target.value)}
|
||||||
|
/>
|
||||||
|
{rules.length > 1 && (
|
||||||
|
<button onClick={() => removeRule(i)} className="text-red-500 hover:text-red-700 p-1">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button onClick={resetForm} className="btn-ghost">انصراف</button>
|
||||||
|
<button onClick={handleSubmit} disabled={createMutation.isPending} className="btn-primary disabled:opacity-50">
|
||||||
|
{createMutation.isPending ? 'در حال ذخیره...' : editingId ? 'بروزرسانی' : 'ایجاد پلن'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plans List */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12 text-gray-400">در حال بارگذاری...</div>
|
||||||
|
) : plans.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-400">هنوز پلنی ایجاد نشده</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<div key={plan.id} className="card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedPlan(expandedPlan === plan.id ? null : plan.id)}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{expandedPlan === plan.id ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">{plan.name}</h3>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span className="badge badge-blue">{cycleLabels[plan.billingCycle]}</span>
|
||||||
|
{plan.description && <span>— {plan.description}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMutation.mutate({ id: plan.id, isActive: !plan.isActive })}
|
||||||
|
className={`p-1 transition-colors ${plan.isActive ? 'text-green-500' : 'text-gray-400'}`}
|
||||||
|
title={plan.isActive ? 'غیرفعال کردن' : 'فعال کردن'}
|
||||||
|
>
|
||||||
|
{plan.isActive ? <ToggleRight className="w-5 h-5" /> : <ToggleLeft className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => startEdit(plan)} className="p-1 text-blue-500 hover:text-blue-700">
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (confirm('حذف این پلن؟')) deleteMutation.mutate(plan.id); }}
|
||||||
|
className="p-1 text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded pricing rules */}
|
||||||
|
{expandedPlan === plan.id && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-500 text-xs">
|
||||||
|
<th className="text-right pb-2">نوع منبع</th>
|
||||||
|
<th className="text-right pb-2">قیمت واحد (تومان)</th>
|
||||||
|
<th className="text-right pb-2">توضیحات</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{plan.pricingRules.map((rule) => (
|
||||||
|
<tr key={rule.id} className="border-t border-gray-50">
|
||||||
|
<td className="py-2 font-medium">{resourceLabels[rule.resourceType]}</td>
|
||||||
|
<td className="py-2 text-green-700 font-mono">{formatPrice(rule.unitPrice)}</td>
|
||||||
|
<td className="py-2 text-gray-500">{rule.description || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
|||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { useAuthStore } from '@/lib/store';
|
import { useAuthStore } from '@/lib/store';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic } from '@/types';
|
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown } from '@/types';
|
||||||
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw } from 'lucide-react';
|
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign } from 'lucide-react';
|
||||||
|
|
||||||
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
|
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
|
||||||
|
|
||||||
@@ -61,6 +61,20 @@ export default function DeployPage() {
|
|||||||
enabled: isAdmin,
|
enabled: isAdmin,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cost calculation for the review step
|
||||||
|
const { data: costData, isLoading: costLoading } = useQuery<CostBreakdown>({
|
||||||
|
queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize],
|
||||||
|
queryFn: () => api.post('/billing/calculate', {
|
||||||
|
runtime: form.runtime,
|
||||||
|
databaseType: form.databaseType,
|
||||||
|
cpuLimit: form.cpuLimit,
|
||||||
|
memoryLimit: form.memoryLimit,
|
||||||
|
replicas: form.replicas,
|
||||||
|
dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}` : undefined,
|
||||||
|
}).then((r) => r.data),
|
||||||
|
enabled: step === 3,
|
||||||
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (data: CreateApplicationDto) => {
|
mutationFn: async (data: CreateApplicationDto) => {
|
||||||
const res = await api.post('/applications', data);
|
const res = await api.post('/applications', data);
|
||||||
@@ -1098,6 +1112,49 @@ export default function DeployPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cost Breakdown */}
|
||||||
|
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 rounded-xl p-5 sm:p-6 border border-emerald-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2 mb-3">
|
||||||
|
<DollarSign className="w-4 h-4 text-emerald-600" /> برآورد هزینه
|
||||||
|
</h3>
|
||||||
|
{costLoading ? (
|
||||||
|
<div className="text-sm text-gray-400 text-center py-3">در حال محاسبه...</div>
|
||||||
|
) : costData ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-center">
|
||||||
|
<div className="bg-white rounded-lg p-3 shadow-sm">
|
||||||
|
<p className="text-xs text-gray-500">ساعتی</p>
|
||||||
|
<p className="text-lg font-bold text-emerald-700">{Number(costData.hourly).toLocaleString('fa-IR')}</p>
|
||||||
|
<p className="text-xs text-gray-400">تومان</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg p-3 shadow-sm ring-2 ring-emerald-200">
|
||||||
|
<p className="text-xs text-gray-500">ماهانه</p>
|
||||||
|
<p className="text-lg font-bold text-emerald-700">{Number(costData.monthly).toLocaleString('fa-IR')}</p>
|
||||||
|
<p className="text-xs text-gray-400">تومان</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg p-3 shadow-sm">
|
||||||
|
<p className="text-xs text-gray-500">سالانه</p>
|
||||||
|
<p className="text-lg font-bold text-emerald-700">{Number(costData.yearly).toLocaleString('fa-IR')}</p>
|
||||||
|
<p className="text-xs text-gray-400">تومان</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{costData.breakdown && costData.breakdown.length > 0 && (
|
||||||
|
<div className="mt-2 pt-3 border-t border-emerald-200/50">
|
||||||
|
<p className="text-xs font-medium text-gray-500 mb-2">جزئیات</p>
|
||||||
|
{costData.breakdown.map((item, i) => (
|
||||||
|
<div key={i} className="flex justify-between text-xs py-1">
|
||||||
|
<span className="text-gray-600">{item.label}</span>
|
||||||
|
<span className="text-gray-900 font-medium">{Number(item.monthly).toLocaleString('fa-IR')} ت/ماه</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-400 text-center py-3">هنوز پلنی تعریف نشده</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
X,
|
X,
|
||||||
LogOut,
|
LogOut,
|
||||||
Boxes,
|
Boxes,
|
||||||
|
Wallet,
|
||||||
|
CreditCard,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type NavItem = { href: string; label: string; icon: ReactNode };
|
type NavItem = { href: string; label: string; icon: ReactNode };
|
||||||
@@ -30,12 +32,14 @@ const userNavItems: NavItem[] = [
|
|||||||
{ href: '/dashboard', label: 'Dashboard', icon: <LayoutDashboard className="w-4 h-4" /> },
|
{ href: '/dashboard', label: 'Dashboard', icon: <LayoutDashboard className="w-4 h-4" /> },
|
||||||
{ href: '/dashboard/apps', label: 'Applications', icon: <Package className="w-4 h-4" /> },
|
{ href: '/dashboard/apps', label: 'Applications', icon: <Package className="w-4 h-4" /> },
|
||||||
{ href: '/dashboard/deploy', label: 'New Deploy', icon: <Rocket className="w-4 h-4" /> },
|
{ href: '/dashboard/deploy', label: 'New Deploy', icon: <Rocket className="w-4 h-4" /> },
|
||||||
|
{ href: '/dashboard/wallet', label: 'Wallet', icon: <Wallet className="w-4 h-4" /> },
|
||||||
{ href: '/dashboard/tickets', label: 'Tickets', icon: <Ticket className="w-4 h-4" /> },
|
{ href: '/dashboard/tickets', label: 'Tickets', icon: <Ticket className="w-4 h-4" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminNavItems: NavItem[] = [
|
const adminNavItems: NavItem[] = [
|
||||||
{ href: '/dashboard/admin/users', label: 'Users', icon: <Users className="w-4 h-4" /> },
|
{ href: '/dashboard/admin/users', label: 'Users', icon: <Users className="w-4 h-4" /> },
|
||||||
{ href: '/dashboard/admin/apps', label: 'All Applications', icon: <Boxes className="w-4 h-4" /> },
|
{ href: '/dashboard/admin/apps', label: 'All Applications', icon: <Boxes className="w-4 h-4" /> },
|
||||||
|
{ href: '/dashboard/admin/billing', label: 'Billing Plans', icon: <CreditCard className="w-4 h-4" /> },
|
||||||
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: <Server className="w-4 h-4" /> },
|
{ href: '/dashboard/admin/clusters', label: 'Clusters', icon: <Server className="w-4 h-4" /> },
|
||||||
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: <Scale className="w-4 h-4" /> },
|
{ href: '/dashboard/admin/pools', label: 'Cluster Pools', icon: <Scale className="w-4 h-4" /> },
|
||||||
{ href: '/dashboard/admin/tickets', label: 'All Tickets', icon: <ClipboardList className="w-4 h-4" /> },
|
{ href: '/dashboard/admin/tickets', label: 'All Tickets', icon: <ClipboardList className="w-4 h-4" /> },
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import type { WalletTransaction, TransactionType } from '@/types';
|
||||||
|
import { Wallet, Plus, ArrowDownCircle, ArrowUpCircle, RotateCcw, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
const txTypeLabels: Record<TransactionType, string> = {
|
||||||
|
charge: 'شارژ',
|
||||||
|
deduction: 'کسر',
|
||||||
|
refund: 'بازگشت',
|
||||||
|
};
|
||||||
|
|
||||||
|
const txTypeColors: Record<TransactionType, string> = {
|
||||||
|
charge: 'text-green-600',
|
||||||
|
deduction: 'text-red-600',
|
||||||
|
refund: 'text-blue-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
const txTypeIcons: Record<TransactionType, React.ReactNode> = {
|
||||||
|
charge: <ArrowDownCircle className="w-4 h-4 text-green-500" />,
|
||||||
|
deduction: <ArrowUpCircle className="w-4 h-4 text-red-500" />,
|
||||||
|
refund: <RotateCcw className="w-4 h-4 text-blue-500" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WalletPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [chargeAmount, setChargeAmount] = useState('');
|
||||||
|
const [showCharge, setShowCharge] = useState(false);
|
||||||
|
|
||||||
|
const { data: walletData, isLoading: walletLoading } = useQuery<{ balance: number }>({
|
||||||
|
queryKey: ['wallet-balance'],
|
||||||
|
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: transactions = [], isLoading: txLoading } = useQuery<WalletTransaction[]>({
|
||||||
|
queryKey: ['wallet-transactions'],
|
||||||
|
queryFn: () => api.get('/billing/wallet/transactions').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const chargeMutation = useMutation({
|
||||||
|
mutationFn: (amount: number) =>
|
||||||
|
api.post('/billing/wallet/charge', { amount, description: 'شارژ کیف پول' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['wallet-transactions'] });
|
||||||
|
toast.success('کیف پول شارژ شد');
|
||||||
|
setChargeAmount('');
|
||||||
|
setShowCharge(false);
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در شارژ'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCharge = () => {
|
||||||
|
const amount = Number(chargeAmount);
|
||||||
|
if (!amount || amount < 1000) {
|
||||||
|
toast.error('حداقل مبلغ شارژ ۱,۰۰۰ تومان');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chargeMutation.mutate(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (n: number) => Number(n).toLocaleString('fa-IR');
|
||||||
|
const formatDate = (d: string) => new Date(d).toLocaleDateString('fa-IR', {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6 animate-fade-in">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title flex items-center gap-2"><Wallet className="w-6 h-6" /> کیف پول</h1>
|
||||||
|
<p className="page-subtitle">مدیریت موجودی و تراکنشها</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance Card */}
|
||||||
|
<div className="card bg-gradient-to-br from-primary-600 to-primary-800 text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm opacity-80">موجودی فعلی</p>
|
||||||
|
<p className="text-3xl font-bold mt-1">
|
||||||
|
{walletLoading ? '...' : `${formatPrice(walletData?.balance ?? 0)}`}
|
||||||
|
<span className="text-lg font-normal mr-2">تومان</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!showCharge && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCharge(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 rounded-xl text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> شارژ کیف پول
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCharge && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-white/20 flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl bg-white/20 text-white placeholder-white/60 border border-white/30 focus:outline-none focus:border-white/60 text-sm"
|
||||||
|
placeholder="مبلغ (تومان) — حداقل ۱,۰۰۰"
|
||||||
|
value={chargeAmount}
|
||||||
|
onChange={(e) => setChargeAmount(e.target.value)}
|
||||||
|
min={1000}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCharge}
|
||||||
|
disabled={chargeMutation.isPending}
|
||||||
|
className="px-5 py-2.5 bg-white text-primary-700 rounded-xl font-semibold text-sm hover:bg-gray-100 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{chargeMutation.isPending ? '...' : 'پرداخت'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCharge(false)}
|
||||||
|
className="px-3 py-2.5 bg-white/10 hover:bg-white/20 rounded-xl text-sm transition-colors"
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick charge amounts */}
|
||||||
|
{showCharge && (
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
{[10000, 50000, 100000, 500000].map((amt) => (
|
||||||
|
<button
|
||||||
|
key={amt}
|
||||||
|
onClick={() => setChargeAmount(String(amt))}
|
||||||
|
className="px-3 py-1 bg-white/10 hover:bg-white/20 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{amt.toLocaleString('fa-IR')} ت
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transactions */}
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<Clock className="w-5 h-5 text-gray-400" /> تاریخچه تراکنشها
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{txLoading ? (
|
||||||
|
<div className="text-center py-8 text-gray-400">در حال بارگذاری...</div>
|
||||||
|
) : transactions.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-400">هنوز تراکنشی ثبت نشده</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{transactions.map((tx) => (
|
||||||
|
<div key={tx.id} className="flex items-center justify-between py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gray-50 flex items-center justify-center">
|
||||||
|
{txTypeIcons[tx.type]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{txTypeLabels[tx.type]}
|
||||||
|
{tx.description && <span className="text-gray-500 font-normal"> — {tx.description}</span>}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">{formatDate(tx.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className={`text-sm font-bold ${txTypeColors[tx.type]}`}>
|
||||||
|
{tx.type === 'deduction' ? '−' : '+'}{formatPrice(tx.amount)} ت
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">مانده: {formatPrice(tx.balanceAfter)} ت</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -238,3 +238,51 @@ export interface TicketStats {
|
|||||||
avgResponseTimeMinutes: number;
|
avgResponseTimeMinutes: number;
|
||||||
byDepartment: Record<string, { total: number; open: number; answered: number; closed: number }>;
|
byDepartment: Record<string, { total: number; open: number; answered: number; closed: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Billing types ──────────────────────────────────
|
||||||
|
|
||||||
|
export type BillingCycle = 'hourly' | 'monthly' | 'yearly';
|
||||||
|
export type PricingResourceType = 'base_fee' | 'cpu_per_core' | 'memory_per_gb' | 'storage_per_gb' | 'database_addon';
|
||||||
|
export type TransactionType = 'charge' | 'deduction' | 'refund';
|
||||||
|
|
||||||
|
export interface PricingRule {
|
||||||
|
id: string;
|
||||||
|
resourceType: PricingResourceType;
|
||||||
|
unitPrice: number;
|
||||||
|
description?: string;
|
||||||
|
planId: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServicePlan {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
billingCycle: BillingCycle;
|
||||||
|
isActive: boolean;
|
||||||
|
pricingRules: PricingRule[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletBalance {
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletTransaction {
|
||||||
|
id: string;
|
||||||
|
type: TransactionType;
|
||||||
|
amount: number;
|
||||||
|
balanceAfter: number;
|
||||||
|
description?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
walletId: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostBreakdown {
|
||||||
|
hourly: number;
|
||||||
|
monthly: number;
|
||||||
|
yearly: number;
|
||||||
|
breakdown: { label: string; hourly: number; monthly: number; yearly: number }[];
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user