AddFirstBookingDiscount

This commit is contained in:
2026-07-16 12:40:52 +03:30
parent 74815cb05c
commit 893035cef2
8 changed files with 326 additions and 3 deletions
@@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** Promo discount on business (custom title + first_booking | always). */
export class AddFirstBookingDiscount1789047300000 implements MigrationInterface {
name = 'AddFirstBookingDiscount1789047300000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "businesses"
ADD COLUMN IF NOT EXISTS "promoDiscountEnabled" boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
ALTER TABLE "businesses"
ADD COLUMN IF NOT EXISTS "promoDiscountTitle" character varying(150)
`);
await queryRunner.query(`
ALTER TABLE "businesses"
ADD COLUMN IF NOT EXISTS "promoDiscountPercent" integer
`);
await queryRunner.query(`
ALTER TABLE "businesses"
ADD COLUMN IF NOT EXISTS "promoDiscountCode" character varying(40)
`);
await queryRunner.query(`
ALTER TABLE "businesses"
ADD COLUMN IF NOT EXISTS "promoDiscountMode" character varying(20) NOT NULL DEFAULT 'first_booking'
`);
await queryRunner.query(`
ALTER TABLE "bookings"
ADD COLUMN IF NOT EXISTS "promoDiscountApplied" boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
ALTER TABLE "bookings"
ADD COLUMN IF NOT EXISTS "promoDiscountPercent" integer
`);
await queryRunner.query(`
ALTER TABLE "bookings"
ADD COLUMN IF NOT EXISTS "promoDiscountTitle" character varying(150)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "bookings" DROP COLUMN IF EXISTS "promoDiscountTitle"`);
await queryRunner.query(`ALTER TABLE "bookings" DROP COLUMN IF EXISTS "promoDiscountPercent"`);
await queryRunner.query(`ALTER TABLE "bookings" DROP COLUMN IF EXISTS "promoDiscountApplied"`);
await queryRunner.query(`ALTER TABLE "businesses" DROP COLUMN IF EXISTS "promoDiscountMode"`);
await queryRunner.query(`ALTER TABLE "businesses" DROP COLUMN IF EXISTS "promoDiscountCode"`);
await queryRunner.query(`ALTER TABLE "businesses" DROP COLUMN IF EXISTS "promoDiscountPercent"`);
await queryRunner.query(`ALTER TABLE "businesses" DROP COLUMN IF EXISTS "promoDiscountTitle"`);
await queryRunner.query(`ALTER TABLE "businesses" DROP COLUMN IF EXISTS "promoDiscountEnabled"`);
}
}
@@ -0,0 +1,91 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* If an older version of 1789047300000 created firstBooking* columns, move them to promo*.
* Safe no-op when promo* already exists and firstBooking* does not.
*/
export class MigrateFirstBookingDiscountToPromo1789047400000 implements MigrationInterface {
name = 'MigrateFirstBookingDiscountToPromo1789047400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "businesses"
ADD COLUMN IF NOT EXISTS "promoDiscountEnabled" boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
ALTER TABLE "businesses"
ADD COLUMN IF NOT EXISTS "promoDiscountTitle" character varying(150)
`);
await queryRunner.query(`
ALTER TABLE "businesses"
ADD COLUMN IF NOT EXISTS "promoDiscountPercent" integer
`);
await queryRunner.query(`
ALTER TABLE "businesses"
ADD COLUMN IF NOT EXISTS "promoDiscountCode" character varying(40)
`);
await queryRunner.query(`
ALTER TABLE "businesses"
ADD COLUMN IF NOT EXISTS "promoDiscountMode" character varying(20) NOT NULL DEFAULT 'first_booking'
`);
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'businesses' AND column_name = 'firstBookingDiscountEnabled'
) THEN
UPDATE businesses SET
"promoDiscountEnabled" = "firstBookingDiscountEnabled",
"promoDiscountPercent" = "firstBookingDiscountPercent",
"promoDiscountCode" = "firstBookingDiscountCode",
"promoDiscountTitle" = COALESCE("promoDiscountTitle", 'اولین رزرو شما'),
"promoDiscountMode" = 'first_booking'
WHERE "firstBookingDiscountEnabled" = true
OR "firstBookingDiscountPercent" IS NOT NULL;
ALTER TABLE businesses DROP COLUMN IF EXISTS "firstBookingDiscountEnabled";
ALTER TABLE businesses DROP COLUMN IF EXISTS "firstBookingDiscountPercent";
ALTER TABLE businesses DROP COLUMN IF EXISTS "firstBookingDiscountCode";
END IF;
END $$;
`);
await queryRunner.query(`
ALTER TABLE "bookings"
ADD COLUMN IF NOT EXISTS "promoDiscountApplied" boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
ALTER TABLE "bookings"
ADD COLUMN IF NOT EXISTS "promoDiscountPercent" integer
`);
await queryRunner.query(`
ALTER TABLE "bookings"
ADD COLUMN IF NOT EXISTS "promoDiscountTitle" character varying(150)
`);
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'bookings' AND column_name = 'firstBookingDiscountApplied'
) THEN
UPDATE bookings SET
"promoDiscountApplied" = "firstBookingDiscountApplied",
"promoDiscountPercent" = "firstBookingDiscountPercent",
"promoDiscountTitle" = COALESCE("promoDiscountTitle", 'اولین رزرو شما')
WHERE "firstBookingDiscountApplied" = true;
ALTER TABLE bookings DROP COLUMN IF EXISTS "firstBookingDiscountApplied";
ALTER TABLE bookings DROP COLUMN IF EXISTS "firstBookingDiscountPercent";
END IF;
END $$;
`);
}
public async down(): Promise<void> {
// irreversible cleanup helper
}
}
+9
View File
@@ -90,6 +90,15 @@ export class BookingEntity {
@Column({ default: false }) @Column({ default: false })
loyaltyRedeemed!: boolean; loyaltyRedeemed!: boolean;
@Column({ default: false })
promoDiscountApplied!: boolean;
@Column({ type: 'int', nullable: true })
promoDiscountPercent!: number | null;
@Column({ type: 'varchar', nullable: true, length: 150 })
promoDiscountTitle!: string | null;
@CreateDateColumn() @CreateDateColumn()
createdAt!: Date; createdAt!: Date;
+19
View File
@@ -66,6 +66,25 @@ export class BusinessEntity {
@Column({ default: false }) @Column({ default: false })
tipsEnabled!: boolean; tipsEnabled!: boolean;
/** Owner-defined promotional percentage discount. */
@Column({ default: false })
promoDiscountEnabled!: boolean;
/** Banner / receipt title, e.g. "اولین رزرو شما" or "تخفیف ویژه تابستان". */
@Column({ type: 'varchar', nullable: true, length: 150 })
promoDiscountTitle!: string | null;
@Column({ type: 'int', nullable: true })
promoDiscountPercent!: number | null;
/** Display code on landing (auto-applied; not a separate redeem step). */
@Column({ type: 'varchar', nullable: true, length: 40 })
promoDiscountCode!: string | null;
/** `first_booking` = only first booking of customer at this business; `always` = every booking while enabled. */
@Column({ type: 'varchar', length: 20, default: 'first_booking' })
promoDiscountMode!: 'first_booking' | 'always';
@ManyToOne(() => BusinessCategoryEntity, (category) => category.businesses, { @ManyToOne(() => BusinessCategoryEntity, (category) => category.businesses, {
onDelete: 'RESTRICT', onDelete: 'RESTRICT',
}) })
+68 -1
View File
@@ -1100,14 +1100,30 @@ export class BookingsService {
} }
const paymentToken = row.paymentToken ?? randomUUID().replace(/-/g, ''); const paymentToken = row.paymentToken ?? randomUUID().replace(/-/g, '');
const promo = await this.resolvePromoDiscount(pending[0]!);
for (const item of pending) { for (const item of pending) {
const catalogPrice = await this.resolveBookingPriceToman(item); const catalogPrice = await this.resolveBookingPriceToman(item);
const free = await this.tryReserveLoyaltyFree(item); const free = await this.tryReserveLoyaltyFree(item);
let amount = free ? 0 : catalogPrice;
let discountApplied = false;
let discountPercent: number | null = null;
let discountTitle: string | null = null;
if (!free && promo) {
amount = Math.round((amount * (100 - promo.percent)) / 100);
discountApplied = true;
discountPercent = promo.percent;
discountTitle = promo.title;
}
item.status = BookingStatus.AWAITING_PAYMENT; item.status = BookingStatus.AWAITING_PAYMENT;
item.paymentToken = paymentToken; item.paymentToken = paymentToken;
item.paymentAmount = String(free ? 0 : catalogPrice); item.paymentAmount = String(amount);
item.loyaltyRedeemed = free; item.loyaltyRedeemed = free;
item.promoDiscountApplied = discountApplied;
item.promoDiscountPercent = discountPercent;
item.promoDiscountTitle = discountTitle;
} }
await this.repo.save(group); await this.repo.save(group);
@@ -1219,6 +1235,57 @@ export class BookingsService {
); );
} }
private async resolvePromoDiscount(
booking: BookingEntity,
): Promise<{ percent: number; title: string } | null> {
const business =
booking.business ??
(await this.businessRepo.findOne({ where: { id: booking.businessId } }));
if (
!business?.promoDiscountEnabled ||
!business.promoDiscountPercent ||
business.promoDiscountPercent < 1 ||
business.promoDiscountPercent > 100
) {
return null;
}
const title = business.promoDiscountTitle?.trim() || 'تخفیف ویژه';
const mode = business.promoDiscountMode === 'always' ? 'always' : 'first_booking';
if (mode === 'always') {
return { percent: business.promoDiscountPercent, title };
}
const priorQb = this.repo
.createQueryBuilder('booking')
.where('booking.customerId = :customerId', { customerId: booking.customerId })
.andWhere('booking.businessId = :businessId', { businessId: booking.businessId })
.andWhere('booking.status IN (:...statuses)', {
statuses: [
BookingStatus.AWAITING_PAYMENT,
BookingStatus.CONFIRMED,
BookingStatus.COMPLETED,
],
});
if (booking.bookingGroupId) {
priorQb.andWhere(
'(booking.bookingGroupId IS NULL OR booking.bookingGroupId <> :groupId)',
{ groupId: booking.bookingGroupId },
);
} else {
priorQb.andWhere('booking.id <> :bookingId', { bookingId: booking.id });
}
const priorCount = await priorQb.getCount();
if (priorCount > 0) {
return null;
}
return { percent: business.promoDiscountPercent, title };
}
private async notifyFreeBookingReady(group: BookingEntity[]): Promise<void> { private async notifyFreeBookingReady(group: BookingEntity[]): Promise<void> {
const leader = group[0]; const leader = group[0];
if (!leader?.customer?.mobile) return; if (!leader?.customer?.mobile) return;
@@ -162,6 +162,38 @@ export class BusinessesService {
} }
Object.assign(business, payload); Object.assign(business, payload);
if (business.promoDiscountEnabled) {
if (
!business.promoDiscountPercent ||
business.promoDiscountPercent < 1 ||
business.promoDiscountPercent > 100
) {
throw new BadRequestException(
'promoDiscountPercent must be between 1 and 100 when discount is enabled',
);
}
const title = business.promoDiscountTitle?.trim();
if (!title) {
throw new BadRequestException(
'promoDiscountTitle is required when discount is enabled',
);
}
business.promoDiscountTitle = title;
if (
business.promoDiscountMode !== 'first_booking' &&
business.promoDiscountMode !== 'always'
) {
business.promoDiscountMode = 'first_booking';
}
const code = business.promoDiscountCode?.trim();
if (!code) {
business.promoDiscountCode = `NEW${business.promoDiscountPercent}`;
} else {
business.promoDiscountCode = code.toUpperCase();
}
}
const saved = await this.repo.save(business); const saved = await this.repo.save(business);
return this.ensureOwnerFromContactPhone(saved); return this.ensureOwnerFromContactPhone(saved);
} }
@@ -127,4 +127,56 @@ export class UpdateBusinessDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
tipsEnabled?: boolean; tipsEnabled?: boolean;
@ApiPropertyOptional({
example: true,
description: 'When true, a promotional percentage discount is active for this business',
})
@IsOptional()
@IsBoolean()
promoDiscountEnabled?: boolean;
@ApiPropertyOptional({
example: 'اولین رزرو شما',
nullable: true,
description: 'Display title for the promo (owner-defined)',
})
@IsOptional()
@IsString()
@MaxLength(150)
promoDiscountTitle?: string | null;
@ApiPropertyOptional({
example: 10,
nullable: true,
description: 'Discount percent (1–100)',
minimum: 1,
maximum: 100,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
promoDiscountPercent?: number | null;
@ApiPropertyOptional({
example: 'NEW10',
nullable: true,
description: 'Display code shown to customers (auto-applied when eligible)',
})
@IsOptional()
@IsString()
@MaxLength(40)
promoDiscountCode?: string | null;
@ApiPropertyOptional({
example: 'first_booking',
enum: ['first_booking', 'always'],
description:
'first_booking = only the customer’s first booking at this business; always = every booking while enabled',
})
@IsOptional()
@IsString()
@Matches(/^(first_booking|always)$/)
promoDiscountMode?: 'first_booking' | 'always';
} }
+2 -2
View File
@@ -1,9 +1,9 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "module": "node16",
"target": "ES2021", "target": "ES2021",
"lib": ["ES2021"], "lib": ["ES2021"],
"moduleResolution": "node", "moduleResolution": "node16",
"types": ["node"], "types": ["node"],
"esModuleInterop": true, "esModuleInterop": true,
"experimentalDecorators": true, "experimentalDecorators": true,