From f7974dd382c520815c2ef6c3b68c363906e09dd9 Mon Sep 17 00:00:00 2001 From: keyhan Date: Mon, 22 Jun 2026 23:44:30 +0330 Subject: [PATCH] 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 --- backend/src/billing/billing.controller.ts | 23 + backend/src/billing/billing.module.ts | 5 +- backend/src/billing/billing.service.ts | 12 + .../billing/pricing-catalog.service.spec.ts | 9 + .../src/billing/pricing-catalog.service.ts | 114 ++++- .../src/billing/public-pricing.controller.ts | 41 ++ .../[lang]/dashboard/admin/billing/page.tsx | 96 +++- .../src/components/landing/LandingPage.tsx | 6 + .../components/landing/sections/Estimator.tsx | 419 ++++++++++++++++++ .../components/landing/sections/Pricing.tsx | 198 +++++++++ .../landing/sections/ServicesBar.tsx | 141 ++++++ frontend/src/i18n/dictionaries/en.ts | 86 ++++ frontend/src/i18n/dictionaries/fa.ts | 86 ++++ 13 files changed, 1226 insertions(+), 10 deletions(-) create mode 100644 backend/src/billing/public-pricing.controller.ts create mode 100644 frontend/src/components/landing/sections/Estimator.tsx create mode 100644 frontend/src/components/landing/sections/Pricing.tsx create mode 100644 frontend/src/components/landing/sections/ServicesBar.tsx diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts index 2998c9c..f5ad16a 100644 --- a/backend/src/billing/billing.controller.ts +++ b/backend/src/billing/billing.controller.ts @@ -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') diff --git a/backend/src/billing/billing.module.ts b/backend/src/billing/billing.module.ts index c8f0f34..2f6af7b 100644 --- a/backend/src/billing/billing.module.ts +++ b/backend/src/billing/billing.module.ts @@ -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], }) diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts index 593ae6a..c8fb59c 100644 --- a/backend/src/billing/billing.service.ts +++ b/backend/src/billing/billing.service.ts @@ -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 ───────────────────────────────────────────── /** diff --git a/backend/src/billing/pricing-catalog.service.spec.ts b/backend/src/billing/pricing-catalog.service.spec.ts index 7b74641..6c1d152 100644 --- a/backend/src/billing/pricing-catalog.service.spec.ts +++ b/backend/src/billing/pricing-catalog.service.spec.ts @@ -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(); diff --git a/backend/src/billing/pricing-catalog.service.ts b/backend/src/billing/pricing-catalog.service.ts index ae64d0b..47ca840 100644 --- a/backend/src/billing/pricing-catalog.service.ts +++ b/backend/src/billing/pricing-catalog.service.ts @@ -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, @InjectRepository(OptionalServiceRate) private readonly optionalRateRepo: Repository, + @InjectRepository(PlatformSetting) + private readonly settingsRepo: Repository, ) {} + /** 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 { 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 { + 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 { + 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 { + 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(); map.set(PricingResourceType.BASE_FEE, 1); diff --git a/backend/src/billing/public-pricing.controller.ts b/backend/src/billing/public-pricing.controller.ts new file mode 100644 index 0000000..29150cf --- /dev/null +++ b/backend/src/billing/public-pricing.controller.ts @@ -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, + }; + } +} diff --git a/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx b/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx index 2b156e2..b127872 100644 --- a/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx +++ b/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx @@ -14,7 +14,7 @@ import type { PricingResourceType, LifecycleSettings, } from '@/types'; -import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react'; +import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe, Percent } from 'lucide-react'; import { Select } from '@/components/ui/select'; import DiscountsSection from './DiscountsSection'; @@ -712,6 +712,8 @@ export default function AdminBillingPage() { )} + + @@ -719,6 +721,98 @@ export default function AdminBillingPage() { ); } +function GlobalDiscountSection() { + const t = useT(); + const g = t.dashboard.billing.globalDiscount; + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(false); + const [percent, setPercent] = useState('0'); + + const { data, isLoading } = useQuery<{ percentOff: number }>({ + queryKey: ['global-discount'], + queryFn: () => api.get('/billing/settings/global-discount').then((r) => r.data), + }); + + const saveMutation = useMutation({ + mutationFn: (percentOff: number) => + api.patch('/billing/settings/global-discount', { percentOff }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['global-discount'] }); + notify.success(g.saved); + setEditing(false); + }, + onError: (err: unknown) => notify.error(err, t.dashboard.billing.saveFailedShort), + }); + + const current = data?.percentOff ?? 0; + + const startEditing = () => { + setPercent(String(current)); + setEditing(true); + }; + + return ( +
+
+
+ +
+

{g.title}

+

{g.subtitle}

+
+
+ {!editing && ( + + )} +
+ + {isLoading ? ( +
{t.common.loading}
+ ) : editing ? ( +
+
+ +
+ setPercent(e.target.value)} + /> + ٪ +
+

{g.hint}

+
+
+ + +
+
+ ) : ( +

0 ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-500' + }`} + > + {current > 0 ? g.active.replace('{p}', String(current)) : g.none} +

+ )} +
+ ); +} + function LifecycleSettingsSection() { const t = useT(); const b = t.dashboard.billing; diff --git a/frontend/src/components/landing/LandingPage.tsx b/frontend/src/components/landing/LandingPage.tsx index 294f56f..da762d3 100644 --- a/frontend/src/components/landing/LandingPage.tsx +++ b/frontend/src/components/landing/LandingPage.tsx @@ -7,9 +7,12 @@ import Lenis from 'lenis'; import { setScroll, setPointer, flashState } from './scroll-store'; import { SiteHeader } from './sections/SiteHeader'; import { Hero } from './sections/Hero'; +import { ServicesBar } from './sections/ServicesBar'; import { Value } from './sections/Value'; import { Features } from './sections/Features'; import { HowItWorks } from './sections/HowItWorks'; +import { Pricing } from './sections/Pricing'; +import { Estimator } from './sections/Estimator'; import { Trust } from './sections/Trust'; import { FinalCta } from './sections/FinalCta'; import { Footer } from './sections/Footer'; @@ -94,9 +97,12 @@ export function LandingPage() {
+ + +