feat(landing,billing): public pricing, global discount, services bar & estimator
Backend
- Add platform-wide global discount (platform_settings: global_discount_percent),
applied centrally in PricingCatalogService.computeTotalsFromDb so it reaches
every real charge (previews, deploys, renewals, upgrades, invoices). Admin
GET/PATCH /billing/settings/global-discount.
- Add unauthenticated PublicPricingController (catalog + calculate) for the
public landing page, returning gross/net and the discount percentage.
- Bill application replicas by the user-selected footprint: app CPU/RAM/storage
now all scale by replica count; the single-replica database stays unscaled.
Frontend
- Landing: Services bar (PaaS active, DBaaS, KaaS/LaaS "coming soon" with
expandable runtime/database menus), transparent Pricing section (per-resource
rate cards with cycle toggle + discount strikethrough), and a cost Estimator
("estimate your package").
- Optional services and the database are priced like runtimes: the estimator
lets users pick their CPU/RAM/storage (and DB type) so the cost scales by need.
- Admin billing: global-discount editor.
- i18n: fa/en strings for services, pricing, estimator and global discount.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,29 @@ export class BillingController {
|
||||
return this.billingService.calculateDeployPayment(req.user.id, dto, dto.cycle, dto.couponCode);
|
||||
}
|
||||
|
||||
// ─── Global discount (platform-wide) ───────────────────────────
|
||||
|
||||
@Get('settings/global-discount')
|
||||
@ApiOperation({ summary: 'Get the platform-wide discount percentage' })
|
||||
async getGlobalDiscount() {
|
||||
return this.billingService.getGlobalDiscount();
|
||||
}
|
||||
|
||||
@Patch('settings/global-discount')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Set the platform-wide discount percentage (Admin)' })
|
||||
async setGlobalDiscount(@Body() body: { percentOff: number }) {
|
||||
if (
|
||||
body.percentOff === undefined ||
|
||||
typeof body.percentOff !== 'number' ||
|
||||
body.percentOff < 0 ||
|
||||
body.percentOff > 100
|
||||
) {
|
||||
throw new BadRequestException('percentOff must be a number between 0 and 100');
|
||||
}
|
||||
return this.billingService.setGlobalDiscount(body.percentOff);
|
||||
}
|
||||
|
||||
// ─── Custom Domain Pricing ─────────────────────────────────────
|
||||
|
||||
@Get('settings/custom-domain-price')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BillingService } from './billing.service';
|
||||
import { BillingController } from './billing.controller';
|
||||
import { PublicPricingController } from './public-pricing.controller';
|
||||
import { DiscountController } from './discount.controller';
|
||||
import { DiscountService } from './discount.service';
|
||||
import { PricingCatalogService } from './pricing-catalog.service';
|
||||
@@ -9,6 +10,7 @@ import { PricingRate } from './entities/pricing-rate.entity';
|
||||
import { AddonRate } from './entities/addon-rate.entity';
|
||||
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
||||
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
||||
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||
import { Wallet } from './entities/wallet.entity';
|
||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||
import { ResourceCredit } from './entities/resource-credit.entity';
|
||||
@@ -34,12 +36,13 @@ import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||
InvoiceLine,
|
||||
Discount,
|
||||
DiscountRedemption,
|
||||
PlatformSetting,
|
||||
]),
|
||||
forwardRef(() => LifecycleModule),
|
||||
forwardRef(() => ApplicationsModule),
|
||||
forwardRef(() => KubernetesModule),
|
||||
],
|
||||
controllers: [BillingController, DiscountController],
|
||||
controllers: [BillingController, PublicPricingController, DiscountController],
|
||||
providers: [BillingService, PricingCatalogService, DiscountService],
|
||||
exports: [BillingService, PricingCatalogService, DiscountService],
|
||||
})
|
||||
|
||||
@@ -48,6 +48,18 @@ export class BillingService {
|
||||
return this.pricingCatalog.updateCatalog(dto);
|
||||
}
|
||||
|
||||
// ─── Global (platform-wide) discount ──────────────────────────────
|
||||
|
||||
/** Current platform-wide discount percentage (0–100). */
|
||||
async getGlobalDiscount(): Promise<{ percentOff: number }> {
|
||||
return { percentOff: await this.pricingCatalog.getGlobalDiscountPercent(true) };
|
||||
}
|
||||
|
||||
/** Set the platform-wide discount percentage (Admin). */
|
||||
async setGlobalDiscount(percentOff: number): Promise<{ percentOff: number }> {
|
||||
return { percentOff: await this.pricingCatalog.setGlobalDiscountPercent(percentOff) };
|
||||
}
|
||||
|
||||
// ─── Cost Calculation ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PricingRate } from './entities/pricing-rate.entity';
|
||||
import { AddonRate } from './entities/addon-rate.entity';
|
||||
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
||||
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
||||
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||
import {
|
||||
AppRuntime,
|
||||
BillingCycle,
|
||||
@@ -47,6 +48,13 @@ describe('PricingCatalogService', () => {
|
||||
create: jest.fn().mockImplementation((x) => x),
|
||||
};
|
||||
|
||||
const settingsRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
save: jest.fn().mockImplementation((x) => Promise.resolve(x)),
|
||||
create: jest.fn().mockImplementation((x) => x),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -56,6 +64,7 @@ describe('PricingCatalogService', () => {
|
||||
{ provide: getRepositoryToken(AddonRate), useValue: addonRepo },
|
||||
{ provide: getRepositoryToken(OptionalServiceProfile), useValue: optionalProfileRepo },
|
||||
{ provide: getRepositoryToken(OptionalServiceRate), useValue: optionalRateRepo },
|
||||
{ provide: getRepositoryToken(PlatformSetting), useValue: settingsRepo },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PricingRate } from './entities/pricing-rate.entity';
|
||||
import { AddonRate } from './entities/addon-rate.entity';
|
||||
import { OptionalServiceProfile } from './entities/optional-service-profile.entity';
|
||||
import { OptionalServiceRate } from './entities/optional-service-rate.entity';
|
||||
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||
import {
|
||||
AppRuntime,
|
||||
BillingCycle,
|
||||
@@ -39,12 +40,22 @@ import {
|
||||
} from './dto/pricing-catalog.dto';
|
||||
import { OptionalServiceResourcesDto } from './dto/optional-service-resources.dto';
|
||||
|
||||
/** PlatformSetting key holding the platform-wide discount percentage (0–100). */
|
||||
export const GLOBAL_DISCOUNT_SETTING_KEY = 'global_discount_percent';
|
||||
|
||||
export interface CyclePrices {
|
||||
hourly: number;
|
||||
monthly: number;
|
||||
yearly: number;
|
||||
}
|
||||
|
||||
export interface CostTotals {
|
||||
hourly: number;
|
||||
monthly: number;
|
||||
yearly: number;
|
||||
breakdown: CostBreakdownLine[];
|
||||
}
|
||||
|
||||
export interface PricingRateRow {
|
||||
resourceType: PricingResourceType;
|
||||
hourlyPrice: number;
|
||||
@@ -129,10 +140,18 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
private readonly optionalProfileRepo: Repository<OptionalServiceProfile>,
|
||||
@InjectRepository(OptionalServiceRate)
|
||||
private readonly optionalRateRepo: Repository<OptionalServiceRate>,
|
||||
@InjectRepository(PlatformSetting)
|
||||
private readonly settingsRepo: Repository<PlatformSetting>,
|
||||
) {}
|
||||
|
||||
/** In-memory cache of the global discount % (TTL-refreshed; single-replica safe). */
|
||||
private cachedGlobalDiscountPct = 0;
|
||||
private cachedGlobalDiscountAt = 0;
|
||||
private static readonly GLOBAL_DISCOUNT_TTL_MS = 30_000;
|
||||
|
||||
async onModuleInit() {
|
||||
await this.ensureDefaults();
|
||||
await this.getGlobalDiscountPercent(true);
|
||||
}
|
||||
|
||||
async ensureDefaults() {
|
||||
@@ -337,13 +356,89 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
async computeTotalsFromDb(dto: CalculateCostDto) {
|
||||
/** Raw totals straight from the catalog, before any platform-wide discount. */
|
||||
async computeTotalsRawFromDb(dto: CalculateCostDto): Promise<CostTotals> {
|
||||
const runtime = dto.runtime as AppRuntime;
|
||||
const rates = await this.getRatesForRuntime(runtime);
|
||||
const optional = await this.getOptionalBillingContext();
|
||||
return this.computeTotalsWithRates(dto, rates, optional);
|
||||
}
|
||||
|
||||
/**
|
||||
* Totals with the platform-wide discount applied. This is the single choke
|
||||
* point every real charge funnels through (calculateCost → invoices), so the
|
||||
* discount automatically reaches previews, deploys, renewals and upgrades.
|
||||
*/
|
||||
async computeTotalsFromDb(dto: CalculateCostDto): Promise<CostTotals> {
|
||||
const raw = await this.computeTotalsRawFromDb(dto);
|
||||
const pct = await this.getGlobalDiscountPercent();
|
||||
return this.applyGlobalDiscount(raw, pct);
|
||||
}
|
||||
|
||||
/** Scale totals (and each breakdown line) by the platform-wide discount. */
|
||||
applyGlobalDiscount(totals: CostTotals, percentOff: number): CostTotals {
|
||||
const pct = Math.min(100, Math.max(0, percentOff || 0));
|
||||
if (pct <= 0) return totals;
|
||||
const factor = 1 - pct / 100;
|
||||
const scale = (n: number) => Math.round(n * factor);
|
||||
return {
|
||||
hourly: scale(totals.hourly),
|
||||
monthly: scale(totals.monthly),
|
||||
yearly: scale(totals.yearly),
|
||||
breakdown: totals.breakdown.map((line) => ({
|
||||
...line,
|
||||
hourly: scale(line.hourly),
|
||||
monthly: scale(line.monthly),
|
||||
yearly: scale(line.yearly),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Platform-wide discount percentage (0–100), cached with a short TTL. */
|
||||
async getGlobalDiscountPercent(force = false): Promise<number> {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!force &&
|
||||
now - this.cachedGlobalDiscountAt < PricingCatalogService.GLOBAL_DISCOUNT_TTL_MS
|
||||
) {
|
||||
return this.cachedGlobalDiscountPct;
|
||||
}
|
||||
try {
|
||||
const setting = await this.settingsRepo.findOne({
|
||||
where: { key: GLOBAL_DISCOUNT_SETTING_KEY },
|
||||
});
|
||||
const parsed = setting ? parseInt(setting.value, 10) : 0;
|
||||
this.cachedGlobalDiscountPct = Number.isFinite(parsed)
|
||||
? Math.min(100, Math.max(0, parsed))
|
||||
: 0;
|
||||
this.cachedGlobalDiscountAt = now;
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to read global discount setting: ${e?.message}`);
|
||||
}
|
||||
return this.cachedGlobalDiscountPct;
|
||||
}
|
||||
|
||||
/** Persist the platform-wide discount percentage (Admin) and refresh the cache. */
|
||||
async setGlobalDiscountPercent(percentOff: number): Promise<number> {
|
||||
const clamped = Math.min(100, Math.max(0, Math.round(percentOff || 0)));
|
||||
let setting = await this.settingsRepo.findOne({
|
||||
where: { key: GLOBAL_DISCOUNT_SETTING_KEY },
|
||||
});
|
||||
if (!setting) {
|
||||
setting = this.settingsRepo.create({
|
||||
key: GLOBAL_DISCOUNT_SETTING_KEY,
|
||||
value: String(clamped),
|
||||
description: 'Platform-wide discount percentage applied to all pricing',
|
||||
});
|
||||
} else {
|
||||
setting.value = String(clamped);
|
||||
}
|
||||
await this.settingsRepo.save(setting);
|
||||
this.cachedGlobalDiscountPct = clamped;
|
||||
this.cachedGlobalDiscountAt = Date.now();
|
||||
return clamped;
|
||||
}
|
||||
|
||||
computeTotalsWithRates(
|
||||
dto: CalculateCostDto,
|
||||
rates: PricingRate[],
|
||||
@@ -739,13 +834,16 @@ export class PricingCatalogService implements OnModuleInit {
|
||||
cpuQty += this.parseCpuToCores(dto.databaseResources.cpuLimit);
|
||||
memoryQty += this.parseMemoryToGb(dto.databaseResources.memoryLimit);
|
||||
}
|
||||
const storageQty =
|
||||
(dto.dbStorageSize
|
||||
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
|
||||
: 0) +
|
||||
(dto.appStorageSize
|
||||
? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0
|
||||
: 0);
|
||||
// App resources (CPU/RAM/storage) bill per replica — each replica is a full
|
||||
// copy of the user-selected footprint. The database is a single-replica
|
||||
// workload, so its storage is billed once regardless of app replicas.
|
||||
const dbStorage = dto.dbStorageSize
|
||||
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
|
||||
: 0;
|
||||
const appStorage = dto.appStorageSize
|
||||
? parseFloat(String(dto.appStorageSize).replace(/Gi$/i, '')) || 0
|
||||
: 0;
|
||||
const storageQty = dbStorage + appStorage * replicas;
|
||||
|
||||
const map = new Map<PricingResourceType, number>();
|
||||
map.set(PricingResourceType.BASE_FEE, 1);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { PricingCatalogService } from './pricing-catalog.service';
|
||||
import { CalculateCostDto } from './dto/billing.dto';
|
||||
|
||||
/**
|
||||
* Unauthenticated pricing endpoints for the public landing page. Exposes the
|
||||
* read-only pricing catalog and a cost estimator, both annotated with the
|
||||
* platform-wide discount so the marketing site can show before/after prices.
|
||||
*/
|
||||
@ApiTags('Public Pricing')
|
||||
@Controller('public/pricing')
|
||||
export class PublicPricingController {
|
||||
constructor(private readonly pricingCatalog: PricingCatalogService) {}
|
||||
|
||||
@Get('catalog')
|
||||
@ApiOperation({ summary: 'Public pricing catalog + platform-wide discount' })
|
||||
async getCatalog() {
|
||||
const [catalog, globalDiscountPercent] = await Promise.all([
|
||||
this.pricingCatalog.getCatalog(),
|
||||
this.pricingCatalog.getGlobalDiscountPercent(),
|
||||
]);
|
||||
return { ...catalog, globalDiscountPercent };
|
||||
}
|
||||
|
||||
@Post('calculate')
|
||||
@ApiOperation({ summary: 'Estimate cost for a configuration (gross + discounted)' })
|
||||
async calculate(@Body() dto: CalculateCostDto) {
|
||||
const [gross, globalDiscountPercent] = await Promise.all([
|
||||
this.pricingCatalog.computeTotalsRawFromDb(dto),
|
||||
this.pricingCatalog.getGlobalDiscountPercent(),
|
||||
]);
|
||||
const net = this.pricingCatalog.applyGlobalDiscount(gross, globalDiscountPercent);
|
||||
return {
|
||||
gross: { hourly: gross.hourly, monthly: gross.monthly, yearly: gross.yearly },
|
||||
net: { hourly: net.hourly, monthly: net.monthly, yearly: net.yearly },
|
||||
breakdown: net.breakdown,
|
||||
globalDiscountPercent,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user