feat(billing): add percentage discount coupons

Admins can create coupon codes that discount specific services (app
runtimes, optional services, managed products, custom-domain addon, or
all) and restrict them to specific users or make them public, with total
and per-user usage caps and an active date window.

Coupons apply in deploy, renewal, and upgrade flows: cost-breakdown lines
are tagged with a service key, the eligible portion is discounted and
capped to the payable amount, the invoice records discountAmount/
discountCode, and the redemption is recorded once when the invoice is
fully paid (covering wallet, gateway, and mixed payments).

- Discount + DiscountRedemption entities; invoice discount columns
- DiscountService (CRUD, validation, redemption) + admin/validate API
- Idempotent schema bootstrap on init so production (synchronize off)
  provisions the tables/columns without a migration runner
- Admin discounts UI, coupon entry in deploy/renewal, invoice discount line
- fa/en strings; discount.service unit spec

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-20 11:48:45 +03:30
parent b232129ee2
commit 49726f1dfd
21 changed files with 2013 additions and 20 deletions
+191
View File
@@ -0,0 +1,191 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { BillingCycle } from '../../common/enums';
import { CalculateCostDto, UpgradeResourcesDto } from './billing.dto';
export class CreateDiscountDto {
@ApiProperty({ example: 'NOWRUZ1403' })
@IsString()
code: string;
@ApiProperty({ example: 'تخفیف نوروزی' })
@IsString()
name: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ example: 20, description: 'Percentage off (1100)' })
@IsInt()
@Min(1)
@Max(100)
percentOff: number;
@ApiPropertyOptional({
type: [String],
description: 'Service keys to target. Empty or ["*"] = all services.',
example: ['optional:redis', 'runtime:nodejs'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
services?: string[];
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isPublic?: boolean;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
allowedUserIds?: string[];
@ApiPropertyOptional({ description: 'Total redemption cap (omit for unlimited)' })
@IsOptional()
@IsInt()
@Min(1)
maxUses?: number;
@ApiPropertyOptional({ description: 'Per-user redemption cap (omit for unlimited)' })
@IsOptional()
@IsInt()
@Min(1)
maxUsesPerUser?: number;
@ApiPropertyOptional({ description: 'ISO start date' })
@IsOptional()
@IsString()
startsAt?: string;
@ApiPropertyOptional({ description: 'ISO end date' })
@IsOptional()
@IsString()
endsAt?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateDiscountDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
code?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ example: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
percentOff?: number;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
services?: string[];
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isPublic?: boolean;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
allowedUserIds?: string[];
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
maxUses?: number | null;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(1)
maxUsesPerUser?: number | null;
@ApiPropertyOptional()
@IsOptional()
@IsString()
startsAt?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsString()
endsAt?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export enum DiscountFlow {
DEPLOY = 'deploy',
RENEWAL = 'renewal',
UPGRADE = 'upgrade',
}
/** Validate a coupon against a concrete charge context to preview the discount. */
export class ValidateDiscountDto {
@ApiProperty({ example: 'NOWRUZ1403' })
@IsString()
code: string;
@ApiProperty({ enum: BillingCycle })
@IsEnum(BillingCycle)
cycle: BillingCycle;
@ApiPropertyOptional({ enum: DiscountFlow, default: DiscountFlow.DEPLOY })
@IsOptional()
@IsEnum(DiscountFlow)
flow?: DiscountFlow;
@ApiPropertyOptional({ type: CalculateCostDto, description: 'Deploy config (deploy flow)' })
@IsOptional()
@ValidateNested()
@Type(() => CalculateCostDto)
config?: CalculateCostDto;
@ApiPropertyOptional({ description: 'Application id (renewal/upgrade flows)' })
@IsOptional()
@IsString()
applicationId?: string;
@ApiPropertyOptional({ type: UpgradeResourcesDto, description: 'Target resources (upgrade flow)' })
@IsOptional()
@ValidateNested()
@Type(() => UpgradeResourcesDto)
upgrade?: UpgradeResourcesDto;
}