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 })
|
||||
reviewPromptedAt!: Date | null;
|
||||
|
||||
/** True when this booking used a loyalty free redeem for its service. */
|
||||
@Column({ default: false })
|
||||
loyaltyRedeemed!: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -47,6 +47,14 @@ export class BusinessServiceEntity {
|
||||
@Column({ default: true })
|
||||
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)
|
||||
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);
|
||||
}
|
||||
|
||||
@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')
|
||||
@Redirect()
|
||||
async tipCallback(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BusinessServiceClassPriceEntity } from '../../entities/business-service
|
||||
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
||||
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { CustomerServiceLoyaltyEntity } from '../../entities/customer-service-loyalty.entity';
|
||||
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
||||
import { UserEntity } from '../../entities/user.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
@@ -25,6 +26,7 @@ import { BookingsService } from './bookings.service';
|
||||
BusinessAvailabilitySlotEntity,
|
||||
BusinessServiceClassEntity,
|
||||
BusinessServiceClassPriceEntity,
|
||||
CustomerServiceLoyaltyEntity,
|
||||
ServiceTemplateEntity,
|
||||
]),
|
||||
AuthModule,
|
||||
@@ -32,5 +34,6 @@ import { BookingsService } from './bookings.service';
|
||||
],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService],
|
||||
exports: [BookingsService],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { BusinessServiceClassPriceEntity } from '../../entities/business-service
|
||||
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
||||
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { CustomerServiceLoyaltyEntity } from '../../entities/customer-service-loyalty.entity';
|
||||
import { UserEntity, UserRole } from '../../entities/user.entity';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { SmsService } from '../auth/sms.service';
|
||||
@@ -66,6 +67,8 @@ export class BookingsService {
|
||||
private readonly classPriceRepo: Repository<BusinessServiceClassPriceEntity>,
|
||||
@InjectRepository(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 parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN;
|
||||
@@ -349,14 +352,25 @@ export class BookingsService {
|
||||
this.applyStatusTransition(row, BookingStatus.COMPLETED, actor);
|
||||
const now = new Date();
|
||||
row.completedAt = now;
|
||||
|
||||
let completedRows: BookingEntity[] = [row];
|
||||
if (row.bookingGroupId) {
|
||||
await this.repo.update(
|
||||
{ bookingGroupId: row.bookingGroupId },
|
||||
{ status: BookingStatus.COMPLETED, completedAt: now },
|
||||
);
|
||||
completedRows = await this.repo.find({
|
||||
where: { bookingGroupId: row.bookingGroupId },
|
||||
relations: { businessService: true },
|
||||
});
|
||||
} else {
|
||||
await this.repo.save(row);
|
||||
}
|
||||
|
||||
for (const completedRow of completedRows) {
|
||||
await this.applyLoyaltyOnComplete(completedRow);
|
||||
}
|
||||
|
||||
const completed = await this.findOne(id, actor);
|
||||
await this.promptReviewIfNeeded(completed);
|
||||
return completed;
|
||||
@@ -379,7 +393,59 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -424,6 +490,12 @@ export class BookingsService {
|
||||
const group = await this.loadPaymentGroup(row);
|
||||
const amountToman = await this.resolveGroupAmountToman(group);
|
||||
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 payment = await this.zarinpal.requestPayment({
|
||||
@@ -438,8 +510,8 @@ export class BookingsService {
|
||||
return { startUrl: '', amountToman, mock: true };
|
||||
}
|
||||
|
||||
for (const row of group) {
|
||||
row.paymentAuthority = payment.authority;
|
||||
for (const item of group) {
|
||||
item.paymentAuthority = payment.authority;
|
||||
}
|
||||
await this.repo.save(group);
|
||||
|
||||
@@ -1028,23 +1100,28 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
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) {
|
||||
if (item.status !== BookingStatus.PENDING) continue;
|
||||
for (const item of pending) {
|
||||
const catalogPrice = await this.resolveBookingPriceToman(item);
|
||||
const free = await this.tryReserveLoyaltyFree(item);
|
||||
item.status = BookingStatus.AWAITING_PAYMENT;
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<
|
||||
Array<{
|
||||
bookingId: string;
|
||||
|
||||
@@ -202,6 +202,20 @@ export class BusinessServicesService {
|
||||
if (dto.serviceTemplateId !== undefined) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -24,4 +24,25 @@ export class UpdateBusinessServiceDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
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