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
+81 -24
View File
@@ -25,6 +25,7 @@ import {
UpdateServicePlanDto,
ChargeWalletDto,
CalculateCostDto,
CalculateDeployCostDto,
RenewApplicationDto,
UpgradeResourcesDto,
CalculateUpgradeCostDto,
@@ -95,6 +96,17 @@ export class BillingController {
return this.billingService.calculateCost(dto);
}
@Post('calculate-deploy')
@ApiOperation({
summary: 'Calculate deploy cost with prepaid resource credits applied',
})
async calculateDeployCost(@Request() req: any, @Body() dto: CalculateDeployCostDto) {
if (!Object.values(BillingCycle).includes(dto.cycle)) {
throw new BadRequestException(`Invalid billing cycle: ${dto.cycle}`);
}
return this.billingService.calculateDeployPayment(req.user.id, dto, dto.cycle);
}
// ─── Custom Domain Pricing ─────────────────────────────────────
@Get('settings/custom-domain-price')
@@ -133,49 +145,94 @@ export class BillingController {
return this.billingService.getTransactions(req.user.id, limit ? parseInt(limit, 10) : 50);
}
@Get('resource-credits')
@ApiOperation({ summary: 'List active prepaid resource credits (from deleted apps)' })
async getResourceCredits(@Request() req: any) {
const credits = await this.billingService.getActiveCredits(req.user.id);
return credits.map((c) => this.billingService.formatCreditForApi(c));
}
@Post('wallet/pay/:applicationId')
@ApiOperation({ summary: 'Pay for an application plan from wallet — activates/renews the app' })
async payForApplication(
@Request() req: any,
@Param('applicationId') applicationId: string,
@Body() body: { planId: string; cycle: string },
@Body() body: { planId?: string; cycle: string; amount?: number },
) {
const cycle = body.cycle as BillingCycle;
if (!Object.values(BillingCycle).includes(cycle)) {
throw new BadRequestException(`Invalid billing cycle: ${body.cycle}`);
}
// Calculate cost
const plan = await this.billingService.findPlan(body.planId);
const costs = await this.billingService.calculateCost({
runtime: plan.runtime,
databaseType: 'none', // Will be refined per-app later
cpuLimit: '500m',
memoryLimit: '512Mi',
replicas: 1,
});
const app = await this.applicationsService.findOne(applicationId, req.user.id);
const amount = cycle === BillingCycle.HOURLY ? costs.hourly
: cycle === BillingCycle.MONTHLY ? costs.monthly
: costs.yearly;
let amount = body.amount;
let planId = body.planId || app.planId || '';
// Deduct from wallet
const tx = await this.billingService.deductWallet(
if (amount === undefined || amount === null) {
if (!body.planId) {
throw new BadRequestException('planId or amount is required');
}
const plan = await this.billingService.findPlan(body.planId);
const costs = await this.billingService.calculateCostForApp({
runtime: app.runtime,
databaseType: app.databaseType,
cpuLimit: app.cpuLimit,
memoryLimit: app.memoryLimit,
replicas: app.replicas,
dbStorageSize: app.dbStorageSize,
appStorageSize: app.appStorageSize,
enableRedis: app.enableRedis,
enableRabbitmq: app.enableRabbitmq,
enableElasticsearch: app.enableElasticsearch,
});
amount = cycle === BillingCycle.HOURLY ? costs.hourly
: cycle === BillingCycle.MONTHLY ? costs.monthly
: costs.yearly;
planId = body.planId;
}
const payment = await this.billingService.resolveAppPayment(
req.user.id,
amount,
`Payment for app ${applicationId} (${cycle}) — plan: ${plan.name}`,
applicationId,
app,
cycle,
);
// Activate/reactivate the application
const app = await this.lifecycleService.activateApp(applicationId, cycle, body.planId);
let tx = null;
if (payment.amountDue > 0) {
tx = await this.billingService.deductWallet(
req.user.id,
payment.amountDue,
`Payment for app ${applicationId} (${cycle})`,
applicationId,
);
}
const activated = await this.lifecycleService.activateApp(
applicationId,
cycle,
planId,
payment.planExpiresAt,
);
return {
transaction: tx,
application: { id: app.id, name: app.name, lifecycleStatus: app.lifecycleStatus, planExpiresAt: app.planExpiresAt },
message: app.lifecycleStatus === AppLifecycleStatus.ACTIVE
? `Application "${app.name}" activated until ${app.planExpiresAt?.toISOString()}`
: `Payment processed — application status: ${app.lifecycleStatus}`,
creditApplied: payment.creditId || null,
waivedAmount: payment.waivedAmount,
paidAmount: payment.amountDue,
application: {
id: activated.id,
name: activated.name,
lifecycleStatus: activated.lifecycleStatus,
planExpiresAt: activated.planExpiresAt,
},
message: payment.waivedAmount > 0
? payment.amountDue > 0
? `Application "${activated.name}" activated — prepaid credit applied; you paid ${payment.amountDue} Toman for additional services.`
: `Application "${activated.name}" activated using your prepaid resource credit (no charge).`
: payment.amountDue > 0
? `Application "${activated.name}" activated until ${activated.planExpiresAt?.toISOString()}`
: `Application "${activated.name}" activated`,
};
}