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 })
|
@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;
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user