Add prepaid resource credits with prorated deploy billing.

When users delete an app before plan expiry, remaining resources become credits for a new deploy. The deploy calculator shows covered vs additional charges, prices optional services correctly, and prorates extras to days left on the credit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-15 17:37:44 +03:30
parent 35dd771f63
commit 5239e8aa94
17 changed files with 1021 additions and 96 deletions
+531 -1
View File
@@ -6,7 +6,7 @@ import { PricingRule } from './entities/pricing-rule.entity';
import { Wallet } from './entities/wallet.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { PlatformSetting } from './entities/platform-setting.entity';
import { TransactionType, BillingCycle, PricingResourceType } from '../common/enums';
import { TransactionType, BillingCycle, PricingResourceType, DatabaseType } from '../common/enums';
import {
CreateServicePlanDto,
UpdateServicePlanDto,
@@ -14,6 +14,8 @@ import {
UpgradeResourcesDto,
} from './dto/billing.dto';
import { Application } from '../applications/entities/application.entity';
import { ResourceCredit } from './entities/resource-credit.entity';
import { IsNull, MoreThan } from 'typeorm';
@Injectable()
export class BillingService {
@@ -25,6 +27,7 @@ export class BillingService {
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
@InjectRepository(PlatformSetting) private settingsRepo: Repository<PlatformSetting>,
@InjectRepository(ResourceCredit) private creditRepo: Repository<ResourceCredit>,
) {}
// ─── Service Plans ────────────────────────────────────────────────
@@ -366,6 +369,11 @@ export class BillingService {
replicas: number;
dbStorageSize?: string;
appStorageSize?: string;
enableRedis?: boolean;
enableRabbitmq?: boolean;
enableElasticsearch?: boolean;
customDomain?: string;
customDomainStatus?: string;
}): Promise<{ hourly: number; monthly: number; yearly: number }> {
const result = await this.calculateCost({
runtime: app.runtime,
@@ -375,6 +383,10 @@ export class BillingService {
replicas: app.replicas,
dbStorageSize: app.dbStorageSize,
appStorageSize: app.appStorageSize,
enableRedis: app.enableRedis,
enableRabbitmq: app.enableRabbitmq,
enableElasticsearch: app.enableElasticsearch,
enableCustomDomain: !!app.customDomain && app.customDomainStatus === 'verified',
});
return { hourly: result.hourly, monthly: result.monthly, yearly: result.yearly };
}
@@ -470,4 +482,522 @@ export class BillingService {
billingCycle: app.billingCycle,
};
}
// ─── Resource credits (prepaid resources after app deletion) ───────
private amountForCycle(
costs: { hourly: number; monthly: number; yearly: number },
cycle: BillingCycle,
): number {
switch (cycle) {
case BillingCycle.HOURLY:
return costs.hourly;
case BillingCycle.MONTHLY:
return costs.monthly;
case BillingCycle.YEARLY:
return costs.yearly;
default:
return costs.monthly;
}
}
private storageGi(size?: string, fallback = 1): number {
if (!size) return fallback;
return parseFloat(String(size).replace(/Gi$/i, '')) || fallback;
}
private appToResourceConfig(
app: Application | CalculateCostDto,
options?: { enableCustomDomain?: boolean },
) {
const enableCustomDomain =
options?.enableCustomDomain ??
('enableCustomDomain' in app
? !!(app as CalculateCostDto).enableCustomDomain
: !!(app as Application).customDomain);
return {
runtime: app.runtime,
databaseType: app.databaseType,
cpuLimit: app.cpuLimit,
memoryLimit: app.memoryLimit,
replicas: app.replicas || 1,
dbStorageSize: app.dbStorageSize,
appStorageSize: app.appStorageSize,
enableRedis: !!app.enableRedis,
enableRabbitmq: !!app.enableRabbitmq,
enableElasticsearch: !!app.enableElasticsearch,
enableCustomDomain,
};
}
async createCreditFromDeletedApp(app: Application): Promise<ResourceCredit | null> {
if (!app.planExpiresAt) return null;
const expiresAt = new Date(app.planExpiresAt);
if (expiresAt <= new Date()) return null;
const credit = this.creditRepo.create({
userId: app.userId,
sourceAppName: app.name,
runtime: app.runtime,
databaseType: app.databaseType,
cpuLimit: app.cpuLimit,
memoryLimit: app.memoryLimit,
replicas: app.replicas,
dbStorageSize: app.dbStorageSize || '1Gi',
appStorageSize: app.appStorageSize || '2Gi',
enableRedis: !!app.enableRedis,
enableRabbitmq: !!app.enableRabbitmq,
enableElasticsearch: !!app.enableElasticsearch,
billingCycle: app.billingCycle || BillingCycle.MONTHLY,
expiresAt,
});
const saved = await this.creditRepo.save(credit);
this.logger.log(`Resource credit created for user ${app.userId} from deleted app ${app.name}`);
return saved;
}
async getActiveCredits(userId: string): Promise<ResourceCredit[]> {
return this.creditRepo.find({
where: {
userId,
consumedAt: IsNull(),
expiresAt: MoreThan(new Date()),
},
order: { expiresAt: 'ASC' },
});
}
formatCreditForApi(credit: ResourceCredit) {
const now = Date.now();
const remainingMs = Math.max(0, new Date(credit.expiresAt).getTime() - now);
const remainingDays = Math.floor(remainingMs / 86400000);
const remainingHours = Math.floor((remainingMs % 86400000) / 3600000);
return {
id: credit.id,
sourceAppName: credit.sourceAppName,
runtime: credit.runtime,
databaseType: credit.databaseType,
cpuLimit: credit.cpuLimit,
memoryLimit: credit.memoryLimit,
replicas: credit.replicas,
dbStorageSize: credit.dbStorageSize,
appStorageSize: credit.appStorageSize,
enableRedis: credit.enableRedis,
enableRabbitmq: credit.enableRabbitmq,
enableElasticsearch: credit.enableElasticsearch,
billingCycle: credit.billingCycle,
expiresAt: credit.expiresAt,
remainingMs,
remainingLabel:
remainingDays > 0 ? `${remainingDays}d ${remainingHours}h` : `${remainingHours}h`,
};
}
configWithinCredit(
config: ReturnType<typeof this.appToResourceConfig>,
credit: ResourceCredit,
): boolean {
if (config.runtime !== credit.runtime) return false;
if (
credit.databaseType !== DatabaseType.NONE &&
config.databaseType !== credit.databaseType
) {
return false;
}
if (this.parseCpuToCores(config.cpuLimit) > this.parseCpuToCores(credit.cpuLimit)) {
return false;
}
if (
this.parseMemoryToGb(config.memoryLimit) > this.parseMemoryToGb(credit.memoryLimit)
) {
return false;
}
if (config.replicas > credit.replicas) return false;
if (config.enableRedis && !credit.enableRedis) return false;
if (config.enableRabbitmq && !credit.enableRabbitmq) return false;
if (config.enableElasticsearch && !credit.enableElasticsearch) return false;
if (this.storageGi(config.dbStorageSize, 1) > this.storageGi(credit.dbStorageSize, 1)) {
return false;
}
if (this.storageGi(config.appStorageSize, 2) > this.storageGi(credit.appStorageSize, 2)) {
return false;
}
return true;
}
/** Credit applies when runtime (and DB type, if any) match — upgrades are charged as extras. */
async findApplicableCredit(
userId: string,
config: ReturnType<typeof this.appToResourceConfig>,
): Promise<ResourceCredit | null> {
const credits = await this.getActiveCredits(userId);
return (
credits.find(
(c) =>
c.runtime === config.runtime &&
(c.databaseType === DatabaseType.NONE ||
c.databaseType === config.databaseType),
) ?? null
);
}
private async costDelta(
base: CalculateCostDto,
withExtras: Partial<CalculateCostDto>,
cycle: BillingCycle,
): Promise<number> {
const a = await this.calculateCost({ ...base, ...withExtras });
const b = await this.calculateCost(base);
return Math.max(0, this.amountForCycle(a, cycle) - this.amountForCycle(b, cycle));
}
private toCalculateDto(
config: ReturnType<typeof this.appToResourceConfig>,
): CalculateCostDto {
return {
runtime: config.runtime,
databaseType: config.databaseType,
cpuLimit: config.cpuLimit,
memoryLimit: config.memoryLimit,
replicas: config.replicas,
dbStorageSize: config.dbStorageSize,
appStorageSize: config.appStorageSize,
enableRedis: config.enableRedis,
enableRabbitmq: config.enableRabbitmq,
enableElasticsearch: config.enableElasticsearch,
enableCustomDomain: config.enableCustomDomain,
};
}
/** Baseline config covered by the prepaid credit (used for isolated add-on pricing). */
private creditBaselineDto(
credit: ResourceCredit,
patch: Partial<CalculateCostDto> = {},
): CalculateCostDto {
return {
runtime: credit.runtime,
databaseType: credit.databaseType,
cpuLimit: credit.cpuLimit,
memoryLimit: credit.memoryLimit,
replicas: credit.replicas,
dbStorageSize: credit.dbStorageSize || '1Gi',
appStorageSize: credit.appStorageSize || '2Gi',
enableRedis: !!credit.enableRedis,
enableRabbitmq: !!credit.enableRabbitmq,
enableElasticsearch: !!credit.enableElasticsearch,
enableCustomDomain: false,
...patch,
};
}
private getCreditProrateFactor(credit: ResourceCredit) {
const created = new Date(credit.createdAt).getTime();
const expires = new Date(credit.expiresAt).getTime();
const now = Date.now();
const totalMs = Math.max(1, expires - created);
const remainingMs = Math.max(0, expires - now);
const factor = Math.min(1, remainingMs / totalMs);
const remainingDays = Math.max(1, Math.ceil(remainingMs / 86400000));
const periodDays = Math.max(1, Math.ceil(totalMs / 86400000));
return { factor, remainingDays, periodDays };
}
private prorateLabel(credit: ResourceCredit): string {
const { remainingDays, periodDays } = this.getCreditProrateFactor(credit);
return `prorated ${remainingDays}/${periodDays} days`;
}
private async addExtraLineProrated(
items: { label: string; amount: number; fullPeriodAmount?: number }[],
from: CalculateCostDto,
to: Partial<CalculateCostDto>,
credit: ResourceCredit,
label: string,
): Promise<void> {
const billCycle = credit.billingCycle || BillingCycle.MONTHLY;
let fullPeriodAmount = await this.costDelta(from, to, billCycle);
if (fullPeriodAmount <= 0) {
fullPeriodAmount = await this.getAddonPriceFromBreakdown(from, to, billCycle);
}
if (fullPeriodAmount <= 0) return;
const { factor } = this.getCreditProrateFactor(credit);
const amount = Math.round(fullPeriodAmount * factor);
if (amount <= 0) return;
items.push({
label: `${label} (${this.prorateLabel(credit)})`,
amount,
fullPeriodAmount,
});
}
/** Fallback: read marginal addon price from cost breakdown labels. */
private async getAddonPriceFromBreakdown(
from: CalculateCostDto,
to: Partial<CalculateCostDto>,
cycle: BillingCycle,
): Promise<number> {
const before = await this.calculateCost(from);
const after = await this.calculateCost({ ...from, ...to });
const labelHints: string[] = [];
if (to.enableRedis) labelHints.push('Redis addon');
if (to.enableRabbitmq) labelHints.push('RabbitMQ addon');
if (to.enableElasticsearch) labelHints.push('Elasticsearch addon');
if (to.enableCustomDomain) labelHints.push('Custom domain + SSL');
if (to.databaseType && to.databaseType !== DatabaseType.NONE) {
labelHints.push('Database addon');
}
let sum = 0;
for (const hint of labelHints) {
const afterLine = after.breakdown.find((b) => b.label === hint);
const beforeLine = before.breakdown.find((b) => b.label === hint);
const afterAmt = afterLine ? this.amountForCycle(afterLine, cycle) : 0;
const beforeAmt = beforeLine ? this.amountForCycle(beforeLine, cycle) : 0;
sum += Math.max(0, afterAmt - beforeAmt);
}
return sum;
}
/**
* Line-item charges for anything beyond the prepaid credit bundle (prorated to remaining credit time).
*/
async calculateExtrasBeyondCredit(
config: ReturnType<typeof this.appToResourceConfig>,
credit: ResourceCredit,
_cycle: BillingCycle,
): Promise<{
total: number;
items: { label: string; amount: number; fullPeriodAmount?: number }[];
}> {
const items: { label: string; amount: number; fullPeriodAmount?: number }[] = [];
const baseline = this.creditBaselineDto(credit);
if (this.parseCpuToCores(config.cpuLimit) > this.parseCpuToCores(credit.cpuLimit)) {
await this.addExtraLineProrated(
items,
baseline,
{ cpuLimit: config.cpuLimit },
credit,
`Extra CPU (${credit.cpuLimit}${config.cpuLimit})`,
);
}
if (this.parseMemoryToGb(config.memoryLimit) > this.parseMemoryToGb(credit.memoryLimit)) {
await this.addExtraLineProrated(
items,
baseline,
{ memoryLimit: config.memoryLimit },
credit,
`Extra memory (${credit.memoryLimit}${config.memoryLimit})`,
);
}
if (config.replicas > credit.replicas) {
await this.addExtraLineProrated(
items,
baseline,
{ replicas: config.replicas },
credit,
`Extra replicas (${credit.replicas}${config.replicas})`,
);
}
const appDb = this.storageGi(config.dbStorageSize, 1);
const creditDb = this.storageGi(credit.dbStorageSize, 1);
if (appDb > creditDb) {
await this.addExtraLineProrated(
items,
baseline,
{ dbStorageSize: `${appDb}Gi` },
credit,
`Extra database storage (${creditDb}Gi → ${appDb}Gi)`,
);
}
const appSt = this.storageGi(config.appStorageSize, 2);
const creditSt = this.storageGi(credit.appStorageSize, 2);
if (appSt > creditSt) {
await this.addExtraLineProrated(
items,
baseline,
{ appStorageSize: `${appSt}Gi` },
credit,
`Extra app storage (${creditSt}Gi → ${appSt}Gi)`,
);
}
if (config.databaseType !== DatabaseType.NONE && credit.databaseType === DatabaseType.NONE) {
await this.addExtraLineProrated(
items,
this.creditBaselineDto(credit, {
databaseType: DatabaseType.NONE,
dbStorageSize: undefined,
}),
{
databaseType: config.databaseType,
dbStorageSize: config.dbStorageSize || '1Gi',
},
credit,
`Database (${config.databaseType})`,
);
}
if (config.enableRedis && !credit.enableRedis) {
await this.addExtraLineProrated(
items,
this.creditBaselineDto(credit, { enableRedis: false }),
{ enableRedis: true },
credit,
'Redis',
);
}
if (config.enableRabbitmq && !credit.enableRabbitmq) {
await this.addExtraLineProrated(
items,
this.creditBaselineDto(credit, { enableRabbitmq: false }),
{ enableRabbitmq: true },
credit,
'RabbitMQ',
);
}
if (config.enableElasticsearch && !credit.enableElasticsearch) {
await this.addExtraLineProrated(
items,
this.creditBaselineDto(credit, { enableElasticsearch: false }),
{ enableElasticsearch: true },
credit,
'Elasticsearch',
);
}
if (config.enableCustomDomain) {
await this.addExtraLineProrated(
items,
this.creditBaselineDto(credit, { enableCustomDomain: false }),
{ enableCustomDomain: true },
credit,
'Custom domain + SSL',
);
}
const total = items.reduce((sum, i) => sum + i.amount, 0);
return { total: Math.round(total), items };
}
/**
* Deploy cost preview — applies prepaid resource credits when the config fits.
*/
async calculateDeployPayment(
userId: string,
dto: CalculateCostDto,
cycle: BillingCycle,
) {
const costs = await this.calculateCost(dto);
const fullAmount = this.amountForCycle(costs, cycle);
const config = this.appToResourceConfig({
...dto,
enableRedis: !!dto.enableRedis,
enableRabbitmq: !!dto.enableRabbitmq,
enableElasticsearch: !!dto.enableElasticsearch,
enableCustomDomain: !!dto.enableCustomDomain,
} as CalculateCostDto);
const credit = await this.findApplicableCredit(userId, config);
if (!credit) {
return {
...costs,
cycle,
fullAmount,
amountDue: fullAmount,
coveredAmount: 0,
waivedAmount: 0,
extrasBreakdown: [],
creditApplied: null,
prepaidCreditUsed: false,
};
}
const { total: extrasDue, items: extrasBreakdown } =
await this.calculateExtrasBeyondCredit(config, credit, cycle);
const waivedAmount = Math.max(0, fullAmount - extrasDue);
const prorate = this.getCreditProrateFactor(credit);
return {
...costs,
cycle,
fullAmount,
amountDue: extrasDue,
coveredAmount: waivedAmount,
waivedAmount,
extrasBreakdown,
creditApplied: this.formatCreditForApi(credit),
prepaidCreditUsed: waivedAmount > 0,
prorateRemainingDays: prorate.remainingDays,
proratePeriodDays: prorate.periodDays,
};
}
/**
* Resolve wallet/gateway payment for an app — consumes a matching credit when applicable.
*/
async resolveAppPayment(
userId: string,
app: Application,
cycle: BillingCycle,
): Promise<{
fullAmount: number;
amountDue: number;
creditId?: string;
planExpiresAt?: Date;
waivedAmount: number;
}> {
const config = this.appToResourceConfig(app, { enableCustomDomain: !!app.customDomain });
const fullCosts = await this.calculateCost(this.toCalculateDto(config));
const fullAmount = this.amountForCycle(fullCosts, cycle);
const credit = await this.findApplicableCredit(userId, config);
if (!credit) {
return { fullAmount, amountDue: fullAmount, waivedAmount: 0 };
}
const { total: extrasDue } = await this.calculateExtrasBeyondCredit(
config,
credit,
cycle,
);
credit.consumedAt = new Date();
credit.appliedApplicationId = app.id;
await this.creditRepo.save(credit);
this.logger.log(
`Applied resource credit ${credit.id} to app ${app.name} — due ${extrasDue} Toman (waived ${fullAmount - extrasDue})`,
);
return {
fullAmount,
amountDue: extrasDue,
creditId: credit.id,
planExpiresAt: credit.expiresAt,
waivedAmount: fullAmount - extrasDue,
};
}
/** @deprecated Use resolveAppPayment */
async applyResourceCredit(
userId: string,
app: Application,
amount: number,
cycle: BillingCycle = BillingCycle.MONTHLY,
): Promise<{ finalAmount: number; creditId?: string; waived: boolean; planExpiresAt?: Date }> {
const resolved = await this.resolveAppPayment(userId, app, cycle);
return {
finalAmount: resolved.amountDue,
creditId: resolved.creditId,
waived: resolved.waivedAmount > 0,
planExpiresAt: resolved.planExpiresAt,
};
}
}