AddServiceLoyalty

This commit is contained in:
2026-07-15 20:41:04 +03:30
parent aa32dae736
commit 74815cb05c
9 changed files with 364 additions and 14 deletions
@@ -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"
`);
}
}
+4
View File
@@ -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;
+8
View File
@@ -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(
+3
View File
@@ -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 {}
+199 -14
View File
@@ -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;
}