diff --git a/src/database/migrations/1789047300000-AddFirstBookingDiscount.ts b/src/database/migrations/1789047300000-AddFirstBookingDiscount.ts new file mode 100644 index 0000000..dd6d5a4 --- /dev/null +++ b/src/database/migrations/1789047300000-AddFirstBookingDiscount.ts @@ -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 { + 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 { + 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"`); + } +} diff --git a/src/database/migrations/1789047400000-MigrateFirstBookingDiscountToPromo.ts b/src/database/migrations/1789047400000-MigrateFirstBookingDiscountToPromo.ts new file mode 100644 index 0000000..e8bec66 --- /dev/null +++ b/src/database/migrations/1789047400000-MigrateFirstBookingDiscountToPromo.ts @@ -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 { + 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 { + // irreversible cleanup helper + } +} diff --git a/src/entities/booking.entity.ts b/src/entities/booking.entity.ts index 4ef6732..88ec5b7 100644 --- a/src/entities/booking.entity.ts +++ b/src/entities/booking.entity.ts @@ -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; diff --git a/src/entities/business.entity.ts b/src/entities/business.entity.ts index c6c5f24..c1661d1 100644 --- a/src/entities/business.entity.ts +++ b/src/entities/business.entity.ts @@ -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', }) diff --git a/src/modules/bookings/bookings.service.ts b/src/modules/bookings/bookings.service.ts index 99a9c9e..b5d934a 100644 --- a/src/modules/bookings/bookings.service.ts +++ b/src/modules/bookings/bookings.service.ts @@ -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 { const leader = group[0]; if (!leader?.customer?.mobile) return; diff --git a/src/modules/businesses/businesses.service.ts b/src/modules/businesses/businesses.service.ts index 56c69fc..6c35fac 100644 --- a/src/modules/businesses/businesses.service.ts +++ b/src/modules/businesses/businesses.service.ts @@ -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); } diff --git a/src/modules/businesses/dto/update-business.dto.ts b/src/modules/businesses/dto/update-business.dto.ts index d16da08..7b47a52 100644 --- a/src/modules/businesses/dto/update-business.dto.ts +++ b/src/modules/businesses/dto/update-business.dto.ts @@ -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'; } diff --git a/tsconfig.json b/tsconfig.json index 712e887..090b0f4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,9 @@ { "compilerOptions": { - "module": "commonjs", + "module": "node16", "target": "ES2021", "lib": ["ES2021"], - "moduleResolution": "node", + "moduleResolution": "node16", "types": ["node"], "esModuleInterop": true, "experimentalDecorators": true,