fix(platform): apply production hardening from audit plan
Close billing, tenancy, migration, build, and CI/CD gaps identified in the audit: wallet/gateway guards, full-UUID namespaces, idempotent migrations with base schema, stateful service stability, safer Dockerfiles/git builds, and platform chart hardening (Redis auth, RollingUpdate, backups, Swagger off). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull, MoreThan, FindOptionsWhere } from 'typeorm';
|
||||
import { Repository, IsNull, MoreThan, FindOptionsWhere, EntityManager } from 'typeorm';
|
||||
import { Wallet } from './entities/wallet.entity';
|
||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||
import { Invoice } from './entities/invoice.entity';
|
||||
@@ -172,6 +172,32 @@ export class BillingService {
|
||||
return { balance: Number(wallet.balance) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the user's wallet inside a transaction with a row-level lock
|
||||
* (SELECT ... FOR UPDATE) so concurrent charge/deduct operations serialize
|
||||
* instead of racing on read-modify-write.
|
||||
*/
|
||||
private async lockWallet(em: EntityManager, userId: string): Promise<Wallet> {
|
||||
let wallet = await em.getRepository(Wallet).findOne({
|
||||
where: { userId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!wallet) {
|
||||
// First-time wallet creation may race; the unique userId column makes
|
||||
// one insert win — re-read with the lock afterwards.
|
||||
try {
|
||||
await em.getRepository(Wallet).insert({ userId, balance: 0 });
|
||||
} catch {
|
||||
/* concurrent insert won — fall through to locked re-read */
|
||||
}
|
||||
wallet = await em.getRepository(Wallet).findOneOrFail({
|
||||
where: { userId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
}
|
||||
return wallet;
|
||||
}
|
||||
|
||||
async chargeWallet(
|
||||
userId: string,
|
||||
amount: number,
|
||||
@@ -180,21 +206,23 @@ export class BillingService {
|
||||
): 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 saved = await this.walletRepo.manager.transaction(async (em) => {
|
||||
const wallet = await this.lockWallet(em, userId);
|
||||
wallet.balance = Number(wallet.balance) + amount;
|
||||
await em.getRepository(Wallet).save(wallet);
|
||||
|
||||
const tx = this.txRepo.create({
|
||||
walletId: wallet.id,
|
||||
type: TransactionType.CHARGE,
|
||||
amount,
|
||||
balanceAfter: wallet.balance,
|
||||
description: description || 'Wallet charge',
|
||||
invoiceId,
|
||||
const tx = em.getRepository(WalletTransaction).create({
|
||||
walletId: wallet.id,
|
||||
type: TransactionType.CHARGE,
|
||||
amount,
|
||||
balanceAfter: wallet.balance,
|
||||
description: description || 'Wallet charge',
|
||||
invoiceId,
|
||||
});
|
||||
return em.getRepository(WalletTransaction).save(tx);
|
||||
});
|
||||
const saved = await this.txRepo.save(tx);
|
||||
|
||||
this.logger.log(`Charged wallet of user ${userId}: +${amount} Toman → balance: ${wallet.balance}`);
|
||||
this.logger.log(`Charged wallet of user ${userId}: +${amount} Toman → balance: ${saved.balanceAfter}`);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -207,26 +235,28 @@ export class BillingService {
|
||||
): 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');
|
||||
}
|
||||
const saved = await this.walletRepo.manager.transaction(async (em) => {
|
||||
const wallet = await this.lockWallet(em, userId);
|
||||
if (Number(wallet.balance) < amount) {
|
||||
throw new BadRequestException('Insufficient wallet balance');
|
||||
}
|
||||
|
||||
wallet.balance = Number(wallet.balance) - amount;
|
||||
await this.walletRepo.save(wallet);
|
||||
wallet.balance = Number(wallet.balance) - amount;
|
||||
await em.getRepository(Wallet).save(wallet);
|
||||
|
||||
const tx = this.txRepo.create({
|
||||
walletId: wallet.id,
|
||||
type: TransactionType.DEDUCTION,
|
||||
amount,
|
||||
balanceAfter: wallet.balance,
|
||||
description: description || 'Service payment',
|
||||
applicationId,
|
||||
invoiceId,
|
||||
const tx = em.getRepository(WalletTransaction).create({
|
||||
walletId: wallet.id,
|
||||
type: TransactionType.DEDUCTION,
|
||||
amount,
|
||||
balanceAfter: wallet.balance,
|
||||
description: description || 'Service payment',
|
||||
applicationId,
|
||||
invoiceId,
|
||||
});
|
||||
return em.getRepository(WalletTransaction).save(tx);
|
||||
});
|
||||
const saved = await this.txRepo.save(tx);
|
||||
|
||||
this.logger.log(`Deducted from wallet of user ${userId}: -${amount} Toman → balance: ${wallet.balance}`);
|
||||
this.logger.log(`Deducted from wallet of user ${userId}: -${amount} Toman → balance: ${saved.balanceAfter}`);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -743,7 +773,9 @@ export class BillingService {
|
||||
yearly: newCost.yearly - currentCost.yearly,
|
||||
};
|
||||
|
||||
// Calculate prorated amount based on remaining time in billing period
|
||||
// Calculate prorated amount based on remaining time in billing period.
|
||||
// Use the price difference of the app's own billing cycle scaled by the
|
||||
// fraction of the cycle that remains — not the hourly rate for all cycles.
|
||||
let proratedAmount = 0;
|
||||
let remainingHours = 0;
|
||||
|
||||
@@ -752,9 +784,18 @@ export class BillingService {
|
||||
const expiresAt = new Date(app.planExpiresAt);
|
||||
remainingHours = Math.max(0, (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60));
|
||||
|
||||
const cycleDifference = this.amountForCycle(difference, app.billingCycle);
|
||||
const cycleHours =
|
||||
app.billingCycle === BillingCycle.HOURLY
|
||||
? 1
|
||||
: app.billingCycle === BillingCycle.MONTHLY
|
||||
? 30 * 24
|
||||
: 365 * 24;
|
||||
|
||||
// Only charge difference if upgrading (not downgrading)
|
||||
if (difference.hourly > 0) {
|
||||
proratedAmount = Math.ceil(difference.hourly * remainingHours);
|
||||
if (cycleDifference > 0) {
|
||||
const remainingFraction = Math.min(1, remainingHours / cycleHours);
|
||||
proratedAmount = Math.ceil(cycleDifference * remainingFraction);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user