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:
keyhan
2026-07-02 19:35:07 +03:30
parent 34c110be6a
commit 22359be40e
55 changed files with 4883 additions and 381 deletions
+73 -32
View File
@@ -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);
}
}