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:
keyhan
2026-06-22 23:44:30 +03:30
parent a58142cc4a
commit f7974dd382
13 changed files with 1226 additions and 10 deletions
+23
View File
@@ -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')
+4 -1
View File
@@ -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],
})
+12
View File
@@ -48,6 +48,18 @@ export class BillingService {
return this.pricingCatalog.updateCatalog(dto);
}
// ─── Global (platform-wide) discount ──────────────────────────────
/** Current platform-wide discount percentage (0100). */
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();
+106 -8
View File
@@ -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 (0100). */
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 (0100), 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,
};
}
}