AddFirstBookingDiscount
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,15 @@ export class BookingEntity {
|
||||
@Column({ default: false })
|
||||
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()
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -66,6 +66,25 @@ export class BusinessEntity {
|
||||
@Column({ default: false })
|
||||
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, {
|
||||
onDelete: 'RESTRICT',
|
||||
})
|
||||
|
||||
@@ -1100,14 +1100,30 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
const paymentToken = row.paymentToken ?? randomUUID().replace(/-/g, '');
|
||||
const promo = await this.resolvePromoDiscount(pending[0]!);
|
||||
|
||||
for (const item of pending) {
|
||||
const catalogPrice = await this.resolveBookingPriceToman(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.paymentToken = paymentToken;
|
||||
item.paymentAmount = String(free ? 0 : catalogPrice);
|
||||
item.paymentAmount = String(amount);
|
||||
item.loyaltyRedeemed = free;
|
||||
item.promoDiscountApplied = discountApplied;
|
||||
item.promoDiscountPercent = discountPercent;
|
||||
item.promoDiscountTitle = discountTitle;
|
||||
}
|
||||
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> {
|
||||
const leader = group[0];
|
||||
if (!leader?.customer?.mobile) return;
|
||||
|
||||
@@ -162,6 +162,38 @@ export class BusinessesService {
|
||||
}
|
||||
|
||||
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);
|
||||
return this.ensureOwnerFromContactPhone(saved);
|
||||
}
|
||||
|
||||
@@ -127,4 +127,56 @@ export class UpdateBusinessDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
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
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"module": "node16",
|
||||
"target": "ES2021",
|
||||
"lib": ["ES2021"],
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "node16",
|
||||
"types": ["node"],
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
|
||||
Reference in New Issue
Block a user