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