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:
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user