AddServiceLoyalty
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddServiceLoyalty1789047200000 implements MigrationInterface {
|
||||||
|
name = 'AddServiceLoyalty1789047200000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "business_services"
|
||||||
|
ADD COLUMN IF NOT EXISTS "loyaltyEnabled" boolean NOT NULL DEFAULT false
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "business_services"
|
||||||
|
ADD COLUMN IF NOT EXISTS "loyaltyEveryN" integer
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "bookings"
|
||||||
|
ADD COLUMN IF NOT EXISTS "loyaltyRedeemed" boolean NOT NULL DEFAULT false
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "customer_service_loyalty" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"customerId" uuid NOT NULL,
|
||||||
|
"businessServiceId" uuid NOT NULL,
|
||||||
|
"paidCount" integer NOT NULL DEFAULT 0,
|
||||||
|
"reservedBookingId" uuid,
|
||||||
|
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT "UQ_customer_service_loyalty" UNIQUE ("customerId", "businessServiceId"),
|
||||||
|
CONSTRAINT "FK_loyalty_customer"
|
||||||
|
FOREIGN KEY ("customerId") REFERENCES "users"("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_loyalty_business_service"
|
||||||
|
FOREIGN KEY ("businessServiceId") REFERENCES "business_services"("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_loyalty_reserved_booking"
|
||||||
|
FOREIGN KEY ("reservedBookingId") REFERENCES "bookings"("id") ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "customer_service_loyalty"`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "bookings" DROP COLUMN IF EXISTS "loyaltyRedeemed"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "business_services" DROP COLUMN IF EXISTS "loyaltyEveryN"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "business_services" DROP COLUMN IF EXISTS "loyaltyEnabled"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,10 @@ export class BookingEntity {
|
|||||||
@Column({ type: 'timestamptz', nullable: true })
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
reviewPromptedAt!: Date | null;
|
reviewPromptedAt!: Date | null;
|
||||||
|
|
||||||
|
/** True when this booking used a loyalty free redeem for its service. */
|
||||||
|
@Column({ default: false })
|
||||||
|
loyaltyRedeemed!: boolean;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ export class BusinessServiceEntity {
|
|||||||
@Column({ default: true })
|
@Column({ default: true })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|
||||||
|
/** When true, customers earn a free visit of this service after N paid completions. */
|
||||||
|
@Column({ default: false })
|
||||||
|
loyaltyEnabled!: boolean;
|
||||||
|
|
||||||
|
/** Number of paid completions required before the next visit is free (e.g. 3 or 5). */
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
loyaltyEveryN!: number | null;
|
||||||
|
|
||||||
@OneToMany(() => BookingEntity, (booking) => booking.businessService)
|
@OneToMany(() => BookingEntity, (booking) => booking.businessService)
|
||||||
bookings!: BookingEntity[];
|
bookings!: BookingEntity[];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Unique,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { BookingEntity } from './booking.entity';
|
||||||
|
import { BusinessServiceEntity } from './business-service.entity';
|
||||||
|
import { UserEntity } from './user.entity';
|
||||||
|
|
||||||
|
@Entity('customer_service_loyalty')
|
||||||
|
@Unique('UQ_customer_service_loyalty', ['customerId', 'businessServiceId'])
|
||||||
|
export class CustomerServiceLoyaltyEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'customerId' })
|
||||||
|
customer!: UserEntity;
|
||||||
|
|
||||||
|
@Column('uuid')
|
||||||
|
customerId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => BusinessServiceEntity, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'businessServiceId' })
|
||||||
|
businessService!: BusinessServiceEntity;
|
||||||
|
|
||||||
|
@Column('uuid')
|
||||||
|
businessServiceId!: string;
|
||||||
|
|
||||||
|
/** Number of paid completed visits toward the next free service. */
|
||||||
|
@Column({ type: 'int', default: 0 })
|
||||||
|
paidCount!: number;
|
||||||
|
|
||||||
|
/** Booking currently holding a reserved free redeem (awaiting payment / confirmed). */
|
||||||
|
@ManyToOne(() => BookingEntity, { nullable: true, onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'reservedBookingId' })
|
||||||
|
reservedBooking!: BookingEntity | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true })
|
||||||
|
reservedBookingId!: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
@@ -89,6 +89,16 @@ export class BookingsController {
|
|||||||
return this.service.getPendingReviews(user);
|
return this.service.getPendingReviews(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('loyalty/:businessServiceId')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.CUSTOMER)
|
||||||
|
getLoyaltyStatus(
|
||||||
|
@Param('businessServiceId') businessServiceId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.getLoyaltyStatus(businessServiceId, user);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('tip/callback')
|
@Get('tip/callback')
|
||||||
@Redirect()
|
@Redirect()
|
||||||
async tipCallback(
|
async tipCallback(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { BusinessServiceClassPriceEntity } from '../../entities/business-service
|
|||||||
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
||||||
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
||||||
import { BusinessEntity } from '../../entities/business.entity';
|
import { BusinessEntity } from '../../entities/business.entity';
|
||||||
|
import { CustomerServiceLoyaltyEntity } from '../../entities/customer-service-loyalty.entity';
|
||||||
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
||||||
import { UserEntity } from '../../entities/user.entity';
|
import { UserEntity } from '../../entities/user.entity';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
@@ -25,6 +26,7 @@ import { BookingsService } from './bookings.service';
|
|||||||
BusinessAvailabilitySlotEntity,
|
BusinessAvailabilitySlotEntity,
|
||||||
BusinessServiceClassEntity,
|
BusinessServiceClassEntity,
|
||||||
BusinessServiceClassPriceEntity,
|
BusinessServiceClassPriceEntity,
|
||||||
|
CustomerServiceLoyaltyEntity,
|
||||||
ServiceTemplateEntity,
|
ServiceTemplateEntity,
|
||||||
]),
|
]),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
@@ -32,5 +34,6 @@ import { BookingsService } from './bookings.service';
|
|||||||
],
|
],
|
||||||
controllers: [BookingsController],
|
controllers: [BookingsController],
|
||||||
providers: [BookingsService],
|
providers: [BookingsService],
|
||||||
|
exports: [BookingsService],
|
||||||
})
|
})
|
||||||
export class BookingsModule {}
|
export class BookingsModule {}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { BusinessServiceClassPriceEntity } from '../../entities/business-service
|
|||||||
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
||||||
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
||||||
import { BusinessEntity } from '../../entities/business.entity';
|
import { BusinessEntity } from '../../entities/business.entity';
|
||||||
|
import { CustomerServiceLoyaltyEntity } from '../../entities/customer-service-loyalty.entity';
|
||||||
import { UserEntity, UserRole } from '../../entities/user.entity';
|
import { UserEntity, UserRole } from '../../entities/user.entity';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { SmsService } from '../auth/sms.service';
|
import { SmsService } from '../auth/sms.service';
|
||||||
@@ -66,6 +67,8 @@ export class BookingsService {
|
|||||||
private readonly classPriceRepo: Repository<BusinessServiceClassPriceEntity>,
|
private readonly classPriceRepo: Repository<BusinessServiceClassPriceEntity>,
|
||||||
@InjectRepository(BookingReviewEntity)
|
@InjectRepository(BookingReviewEntity)
|
||||||
private readonly reviewRepo: Repository<BookingReviewEntity>,
|
private readonly reviewRepo: Repository<BookingReviewEntity>,
|
||||||
|
@InjectRepository(CustomerServiceLoyaltyEntity)
|
||||||
|
private readonly loyaltyRepo: Repository<CustomerServiceLoyaltyEntity>,
|
||||||
) {
|
) {
|
||||||
const raw = this.config.get<string | undefined>('BOOKING_MIN_LEAD_MINUTES');
|
const raw = this.config.get<string | undefined>('BOOKING_MIN_LEAD_MINUTES');
|
||||||
const parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN;
|
const parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN;
|
||||||
@@ -349,14 +352,25 @@ export class BookingsService {
|
|||||||
this.applyStatusTransition(row, BookingStatus.COMPLETED, actor);
|
this.applyStatusTransition(row, BookingStatus.COMPLETED, actor);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
row.completedAt = now;
|
row.completedAt = now;
|
||||||
|
|
||||||
|
let completedRows: BookingEntity[] = [row];
|
||||||
if (row.bookingGroupId) {
|
if (row.bookingGroupId) {
|
||||||
await this.repo.update(
|
await this.repo.update(
|
||||||
{ bookingGroupId: row.bookingGroupId },
|
{ bookingGroupId: row.bookingGroupId },
|
||||||
{ status: BookingStatus.COMPLETED, completedAt: now },
|
{ status: BookingStatus.COMPLETED, completedAt: now },
|
||||||
);
|
);
|
||||||
|
completedRows = await this.repo.find({
|
||||||
|
where: { bookingGroupId: row.bookingGroupId },
|
||||||
|
relations: { businessService: true },
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.repo.save(row);
|
await this.repo.save(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const completedRow of completedRows) {
|
||||||
|
await this.applyLoyaltyOnComplete(completedRow);
|
||||||
|
}
|
||||||
|
|
||||||
const completed = await this.findOne(id, actor);
|
const completed = await this.findOne(id, actor);
|
||||||
await this.promptReviewIfNeeded(completed);
|
await this.promptReviewIfNeeded(completed);
|
||||||
return completed;
|
return completed;
|
||||||
@@ -379,7 +393,59 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.applyStatusTransition(row, BookingStatus.CANCELLED, actor);
|
this.applyStatusTransition(row, BookingStatus.CANCELLED, actor);
|
||||||
return this.repo.save(row);
|
const saved = await this.repo.save(row);
|
||||||
|
await this.releaseLoyaltyReservation(saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLoyaltyStatus(
|
||||||
|
businessServiceId: string,
|
||||||
|
actor: AuthUser,
|
||||||
|
): Promise<{
|
||||||
|
enabled: boolean;
|
||||||
|
everyN: number | null;
|
||||||
|
paidCount: number;
|
||||||
|
nextIsFree: boolean;
|
||||||
|
remainingPaidVisits: number | null;
|
||||||
|
}> {
|
||||||
|
const service = await this.businessServiceRepo.findOne({
|
||||||
|
where: { id: businessServiceId },
|
||||||
|
});
|
||||||
|
if (!service) {
|
||||||
|
throw new NotFoundException('Business service not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const everyN = service.loyaltyEveryN;
|
||||||
|
const enabled = service.loyaltyEnabled === true && !!everyN && everyN >= 2;
|
||||||
|
if (!enabled) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
everyN: everyN ?? null,
|
||||||
|
paidCount: 0,
|
||||||
|
nextIsFree: false,
|
||||||
|
remainingPaidVisits: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const loyalty = await this.loyaltyRepo.findOne({
|
||||||
|
where: { customerId: actor.id, businessServiceId },
|
||||||
|
});
|
||||||
|
const paidCount = loyalty?.paidCount ?? 0;
|
||||||
|
const reservedElsewhere =
|
||||||
|
!!loyalty?.reservedBookingId &&
|
||||||
|
loyalty.reservedBookingId.length > 0;
|
||||||
|
const nextIsFree = paidCount >= everyN && !reservedElsewhere;
|
||||||
|
const remainingPaidVisits = nextIsFree
|
||||||
|
? 0
|
||||||
|
: Math.max(everyN - paidCount, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
everyN,
|
||||||
|
paidCount,
|
||||||
|
nextIsFree,
|
||||||
|
remainingPaidVisits,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByPaymentToken(
|
async findByPaymentToken(
|
||||||
@@ -424,6 +490,12 @@ export class BookingsService {
|
|||||||
const group = await this.loadPaymentGroup(row);
|
const group = await this.loadPaymentGroup(row);
|
||||||
const amountToman = await this.resolveGroupAmountToman(group);
|
const amountToman = await this.resolveGroupAmountToman(group);
|
||||||
const leader = group[0]!;
|
const leader = group[0]!;
|
||||||
|
|
||||||
|
if (amountToman <= 0) {
|
||||||
|
await this.completePaymentGroup(group, `free-${Date.now()}`);
|
||||||
|
return { startUrl: '', amountToman: 0, mock: true };
|
||||||
|
}
|
||||||
|
|
||||||
const description = `رزرو ${leader.business?.title ?? 'منتایم'}`;
|
const description = `رزرو ${leader.business?.title ?? 'منتایم'}`;
|
||||||
|
|
||||||
const payment = await this.zarinpal.requestPayment({
|
const payment = await this.zarinpal.requestPayment({
|
||||||
@@ -438,8 +510,8 @@ export class BookingsService {
|
|||||||
return { startUrl: '', amountToman, mock: true };
|
return { startUrl: '', amountToman, mock: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const row of group) {
|
for (const item of group) {
|
||||||
row.paymentAuthority = payment.authority;
|
item.paymentAuthority = payment.authority;
|
||||||
}
|
}
|
||||||
await this.repo.save(group);
|
await this.repo.save(group);
|
||||||
|
|
||||||
@@ -1028,23 +1100,28 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const paymentToken = row.paymentToken ?? randomUUID().replace(/-/g, '');
|
const paymentToken = row.paymentToken ?? randomUUID().replace(/-/g, '');
|
||||||
const amountPerBooking = await Promise.all(
|
|
||||||
group.map(async (item) => ({
|
|
||||||
id: item.id,
|
|
||||||
amount: await this.resolveBookingPriceToman(item),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const amountById = new Map(amountPerBooking.map((item) => [item.id, item.amount]));
|
|
||||||
|
|
||||||
for (const item of group) {
|
for (const item of pending) {
|
||||||
if (item.status !== BookingStatus.PENDING) continue;
|
const catalogPrice = await this.resolveBookingPriceToman(item);
|
||||||
|
const free = await this.tryReserveLoyaltyFree(item);
|
||||||
item.status = BookingStatus.AWAITING_PAYMENT;
|
item.status = BookingStatus.AWAITING_PAYMENT;
|
||||||
item.paymentToken = paymentToken;
|
item.paymentToken = paymentToken;
|
||||||
item.paymentAmount = String(amountById.get(item.id) ?? 0);
|
item.paymentAmount = String(free ? 0 : catalogPrice);
|
||||||
|
item.loyaltyRedeemed = free;
|
||||||
}
|
}
|
||||||
await this.repo.save(group);
|
await this.repo.save(group);
|
||||||
|
|
||||||
if (!options?.skipNotify) {
|
const totalAmount = await this.resolveGroupAmountToman(
|
||||||
|
group.filter((item) => item.paymentToken === paymentToken),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (totalAmount <= 0) {
|
||||||
|
const freeGroup = group.filter((item) => item.paymentToken === paymentToken);
|
||||||
|
await this.completePaymentGroup(freeGroup, `free-${Date.now()}`);
|
||||||
|
if (!options?.skipNotify) {
|
||||||
|
await this.notifyFreeBookingReady(freeGroup);
|
||||||
|
}
|
||||||
|
} else if (!options?.skipNotify) {
|
||||||
await this.notifyPaymentReady(group);
|
await this.notifyPaymentReady(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1142,6 +1219,114 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async notifyFreeBookingReady(group: BookingEntity[]): Promise<void> {
|
||||||
|
const leader = group[0];
|
||||||
|
if (!leader?.customer?.mobile) return;
|
||||||
|
|
||||||
|
const businessTitle = leader.business?.title ?? 'کسبوکار';
|
||||||
|
await this.sms.sendSms(
|
||||||
|
leader.customer.mobile,
|
||||||
|
`رزرو شما در «${businessTitle}» تأیید شد (رایگان — برنامه وفاداری).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrCreateLoyalty(
|
||||||
|
customerId: string,
|
||||||
|
businessServiceId: string,
|
||||||
|
): Promise<CustomerServiceLoyaltyEntity> {
|
||||||
|
let row = await this.loyaltyRepo.findOne({
|
||||||
|
where: { customerId, businessServiceId },
|
||||||
|
});
|
||||||
|
if (!row) {
|
||||||
|
row = this.loyaltyRepo.create({
|
||||||
|
customerId,
|
||||||
|
businessServiceId,
|
||||||
|
paidCount: 0,
|
||||||
|
reservedBookingId: null,
|
||||||
|
});
|
||||||
|
row = await this.loyaltyRepo.save(row);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reserve a free redeem at approval time. Returns true when this booking is free. */
|
||||||
|
private async tryReserveLoyaltyFree(booking: BookingEntity): Promise<boolean> {
|
||||||
|
const service =
|
||||||
|
booking.businessService ??
|
||||||
|
(await this.businessServiceRepo.findOne({
|
||||||
|
where: { id: booking.businessServiceId },
|
||||||
|
}));
|
||||||
|
if (!service?.loyaltyEnabled || !service.loyaltyEveryN || service.loyaltyEveryN < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loyalty = await this.getOrCreateLoyalty(
|
||||||
|
booking.customerId,
|
||||||
|
booking.businessServiceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loyalty.reservedBookingId && loyalty.reservedBookingId !== booking.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (loyalty.paidCount < service.loyaltyEveryN) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
loyalty.reservedBookingId = booking.id;
|
||||||
|
await this.loyaltyRepo.save(loyalty);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async releaseLoyaltyReservation(booking: BookingEntity): Promise<void> {
|
||||||
|
if (!booking.loyaltyRedeemed) return;
|
||||||
|
|
||||||
|
const loyalty = await this.loyaltyRepo.findOne({
|
||||||
|
where: {
|
||||||
|
customerId: booking.customerId,
|
||||||
|
businessServiceId: booking.businessServiceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!loyalty) return;
|
||||||
|
|
||||||
|
if (loyalty.reservedBookingId === booking.id) {
|
||||||
|
loyalty.reservedBookingId = null;
|
||||||
|
await this.loyaltyRepo.save(loyalty);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.loyaltyRedeemed) {
|
||||||
|
booking.loyaltyRedeemed = false;
|
||||||
|
await this.repo.save(booking);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyLoyaltyOnComplete(booking: BookingEntity): Promise<void> {
|
||||||
|
const service =
|
||||||
|
booking.businessService ??
|
||||||
|
(await this.businessServiceRepo.findOne({
|
||||||
|
where: { id: booking.businessServiceId },
|
||||||
|
}));
|
||||||
|
if (!service?.loyaltyEnabled || !service.loyaltyEveryN || service.loyaltyEveryN < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loyalty = await this.getOrCreateLoyalty(
|
||||||
|
booking.customerId,
|
||||||
|
booking.businessServiceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (booking.loyaltyRedeemed) {
|
||||||
|
loyalty.paidCount = 0;
|
||||||
|
if (loyalty.reservedBookingId === booking.id) {
|
||||||
|
loyalty.reservedBookingId = null;
|
||||||
|
}
|
||||||
|
await this.loyaltyRepo.save(loyalty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loyalty.paidCount += 1;
|
||||||
|
await this.loyaltyRepo.save(loyalty);
|
||||||
|
}
|
||||||
|
|
||||||
async getPendingReviews(actor: AuthUser): Promise<
|
async getPendingReviews(actor: AuthUser): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
|||||||
@@ -202,6 +202,20 @@ export class BusinessServicesService {
|
|||||||
if (dto.serviceTemplateId !== undefined) {
|
if (dto.serviceTemplateId !== undefined) {
|
||||||
row.serviceTemplateId = dto.serviceTemplateId;
|
row.serviceTemplateId = dto.serviceTemplateId;
|
||||||
}
|
}
|
||||||
|
if (dto.loyaltyEnabled !== undefined) {
|
||||||
|
row.loyaltyEnabled = dto.loyaltyEnabled;
|
||||||
|
if (!dto.loyaltyEnabled && dto.loyaltyEveryN === undefined) {
|
||||||
|
row.loyaltyEveryN = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dto.loyaltyEveryN !== undefined) {
|
||||||
|
row.loyaltyEveryN = dto.loyaltyEveryN;
|
||||||
|
}
|
||||||
|
if (row.loyaltyEnabled && (!row.loyaltyEveryN || row.loyaltyEveryN < 2)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'loyaltyEveryN must be at least 2 when loyalty is enabled',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.repo.save(row);
|
return this.repo.save(row);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,25 @@ export class UpdateBusinessServiceDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: true,
|
||||||
|
description: 'Enable free-service loyalty for this service',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
loyaltyEnabled?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 3,
|
||||||
|
nullable: true,
|
||||||
|
description: 'After this many paid visits, the next visit of this service is free',
|
||||||
|
minimum: 2,
|
||||||
|
maximum: 100,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(2)
|
||||||
|
@Max(100)
|
||||||
|
loyaltyEveryN?: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user