From 464f5cb49ec004f3346d726a6d962824b6fd427c Mon Sep 17 00:00:00 2001 From: shancheas Date: Wed, 11 Jun 2025 08:53:12 +0700 Subject: [PATCH] feat: add booking parent relationship to transactions and implement rescheduling functionality --- ...39749-add-booking-parent-to-transaction.ts | 24 ++++ .../reschedule-verification.manager.ts | 17 +-- .../usecases/managers/reschedule.manager.ts | 116 ++++++++++++++++++ .../order/infrastructure/order.controller.ts | 13 +- .../booking-online/order/order.module.ts | 7 +- .../data/models/transaction.model.ts | 16 +++ .../domain/entities/transaction.entity.ts | 2 + src/services/whatsapp/whatsapp.service.ts | 80 +++++++++++- 8 files changed, 262 insertions(+), 13 deletions(-) create mode 100644 src/database/migrations/1749604239749-add-booking-parent-to-transaction.ts create mode 100644 src/modules/booking-online/order/domain/usecases/managers/reschedule.manager.ts diff --git a/src/database/migrations/1749604239749-add-booking-parent-to-transaction.ts b/src/database/migrations/1749604239749-add-booking-parent-to-transaction.ts new file mode 100644 index 0000000..b9a7012 --- /dev/null +++ b/src/database/migrations/1749604239749-add-booking-parent-to-transaction.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingParentToTransaction1749604239749 + implements MigrationInterface +{ + name = 'AddBookingParentToTransaction1749604239749'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "transactions" ADD "parent_id" uuid`); + + await queryRunner.query( + `ALTER TABLE "transactions" ADD CONSTRAINT "FK_413e95171729ba18cabce1c31e3" FOREIGN KEY ("parent_id") REFERENCES "transactions"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "transactions" DROP CONSTRAINT "FK_413e95171729ba18cabce1c31e3"`, + ); + await queryRunner.query( + `ALTER TABLE "transactions" DROP COLUMN "parent_id"`, + ); + } +} diff --git a/src/modules/booking-online/order/domain/usecases/managers/reschedule-verification.manager.ts b/src/modules/booking-online/order/domain/usecases/managers/reschedule-verification.manager.ts index 098b92b..b40d513 100644 --- a/src/modules/booking-online/order/domain/usecases/managers/reschedule-verification.manager.ts +++ b/src/modules/booking-online/order/domain/usecases/managers/reschedule-verification.manager.ts @@ -57,7 +57,7 @@ export class RescheduleVerificationManager { phone: transaction.customer_phone, code: otp.toString(), }); - // whatsapp.bookingReschedule({ + // whatsapp.bookingRescheduleOTP({ // phone: transaction.customer_phone, // code: otp.toString(), // name: transaction.customer_name, @@ -76,17 +76,17 @@ export class RescheduleVerificationManager { async verifyOtp( booking_id: string, code: number, - ): Promise<{ success: boolean; message: string }> { + ): Promise { const verification = await this.rescheduleVerificationRepository.findOne({ where: { booking_id, code }, order: { created_at: 'DESC' }, }); if (!verification) { - return { + throw new UnprocessableEntityException({ success: false, - message: 'No verification code found for this booking.', - }; + message: 'Verification code not match', + }); } // Optionally, you can implement OTP expiration logic here @@ -95,12 +95,15 @@ export class RescheduleVerificationManager { // Increment tried count verification.tried = (verification.tried || 0) + 1; await this.rescheduleVerificationRepository.save(verification); - return { success: false, message: 'Invalid verification code.' }; + throw new UnprocessableEntityException({ + success: false, + message: 'Invalid verification code.', + }); } // Optionally, you can mark the verification as used or verified here - return { success: true, message: 'Verification successful.' }; + return verification; } async findDetailByBookingId(bookingId: string): Promise { diff --git a/src/modules/booking-online/order/domain/usecases/managers/reschedule.manager.ts b/src/modules/booking-online/order/domain/usecases/managers/reschedule.manager.ts new file mode 100644 index 0000000..6d526bb --- /dev/null +++ b/src/modules/booking-online/order/domain/usecases/managers/reschedule.manager.ts @@ -0,0 +1,116 @@ +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { TransactionModel } from 'src/modules/transaction/transaction/data/models/transaction.model'; +import { STATUS } from 'src/core/strings/constants/base.constants'; +import { v4 as uuidv4 } from 'uuid'; +import { TransactionDataService } from 'src/modules/transaction/transaction/data/services/transaction-data.service'; +import { generateInvoiceCodeHelper } from 'src/modules/transaction/transaction/domain/usecases/managers/helpers/generate-invoice-code.helper'; +import * as moment from 'moment'; +import { TransactionItemModel } from 'src/modules/transaction/transaction/data/models/transaction-item.model'; +import { RescheduleVerificationModel } from '../../../data/models/reschedule-verification.model'; +import { WhatsappService } from 'src/services/whatsapp/whatsapp.service'; + +@Injectable() +export class RescheduleManager { + constructor(private serviceData: TransactionDataService) {} + + async reschedule(data: RescheduleVerificationModel) { + const transaction = await this.serviceData.getRepository().findOne({ + relations: ['children_transactions', 'items'], + where: { id: data.booking_id }, + }); + + const today = moment().startOf('day'); + const rescheduleDate = moment(data.reschedule_date, 'DD-MM-YYYY'); + const rescheduleDateStartOfDay = rescheduleDate.startOf('day'); + + //TODO: validate session period priority + + if (rescheduleDateStartOfDay.isSameOrBefore(today)) { + throw new UnprocessableEntityException( + 'Reschedule date must be in the future', + ); + } + + if (!transaction) { + throw new UnprocessableEntityException('Transaction not found'); + } + + if (transaction.status !== STATUS.SETTLED) { + throw new UnprocessableEntityException('Transaction is not settled'); + } + + if (transaction.children_transactions.length > 0) { + throw new UnprocessableEntityException('Transaction already rescheduled'); + } + + if (transaction.parent_id) { + throw new UnprocessableEntityException('Transaction is a reschedule'); + } + + const id = uuidv4(); + const invoiceCode = await generateInvoiceCodeHelper( + this.serviceData, + 'BOOK', + ); + + const items = this.makeItemZeroPrice(transaction.items); + const transactionData = this.makeTransactionZeroPrice(transaction); + + Object.assign(transactionData, { + parent_id: transaction.id, + id, + invoice_code: invoiceCode, + status: STATUS.SETTLED, + invoice_date: rescheduleDate.format('YYYY-MM-DD'), + created_at: moment().unix() * 1000, + updated_at: moment().unix() * 1000, + items, + }); + + await this.serviceData.getRepository().save(transactionData); + + const whatsapp = new WhatsappService(); + whatsapp.rescheduleCreated({ + id: transactionData.id, + name: transactionData.customer_name, + phone: transactionData.customer_phone, + time: moment(transactionData.invoice_date).unix() * 1000, + code: data.code.toString(), + }); + + return transactionData; + } + + private makeItemZeroPrice(items: TransactionItemModel[]) { + return items.map((item) => { + return { + ...item, + id: uuidv4(), + item_price: 0, + total_price: 0, + total_hpp: 0, + total_profit: 0, + total_profit_share: 0, + payment_total_dpp: 0, + payment_total_tax: 0, + total_net_price: 0, + }; + }); + } + + private makeTransactionZeroPrice(transaction: TransactionModel) { + return { + ...transaction, + payment_sub_total: 0, + payment_discount_total: 0, + payment_total: 0, + payment_total_pay: 0, + payment_total_share: 0, + payment_total_tax: 0, + payment_total_profit: 0, + payment_total_net_profit: 0, + payment_total_dpp: 0, + discount_percentage: 0, + }; + } +} diff --git a/src/modules/booking-online/order/infrastructure/order.controller.ts b/src/modules/booking-online/order/infrastructure/order.controller.ts index 6e423d0..db38439 100644 --- a/src/modules/booking-online/order/infrastructure/order.controller.ts +++ b/src/modules/booking-online/order/infrastructure/order.controller.ts @@ -15,6 +15,7 @@ import { RescheduleVerificationOTP, } from './dto/reschedule.dto'; import { RescheduleVerificationManager } from '../domain/usecases/managers/reschedule-verification.manager'; +import { RescheduleManager } from '../domain/usecases/managers/reschedule.manager'; @ApiTags('Booking Order') @Controller('v1/booking') @@ -25,6 +26,7 @@ export class BookingOrderController { private serviceData: TransactionDataService, private midtransService: MidtransService, private rescheduleVerification: RescheduleVerificationManager, + private rescheduleManager: RescheduleManager, ) {} @Post() @@ -72,9 +74,16 @@ export class BookingOrderController { +data.code, ); - const transaction = await this.get(data.booking_id); + const reschedule = await this.rescheduleManager.reschedule(result); + const transaction = await this.get(reschedule.id); - return { ...result, transaction }; + return { + id: reschedule.id, + phone_number: result.phone_number, + name: result.name, + reschedule_date: result.reschedule_date, + transaction, + }; } @Get(':id') diff --git a/src/modules/booking-online/order/order.module.ts b/src/modules/booking-online/order/order.module.ts index 84c8626..d337988 100644 --- a/src/modules/booking-online/order/order.module.ts +++ b/src/modules/booking-online/order/order.module.ts @@ -13,6 +13,7 @@ import { MidtransModule } from 'src/modules/configuration/midtrans/midtrans.modu import { CqrsModule } from '@nestjs/cqrs'; import { RescheduleVerificationModel } from './data/models/reschedule-verification.model'; import { RescheduleVerificationManager } from './domain/usecases/managers/reschedule-verification.manager'; +import { RescheduleManager } from './domain/usecases/managers/reschedule.manager'; @Module({ imports: [ ConfigModule.forRoot(), @@ -26,6 +27,10 @@ import { RescheduleVerificationManager } from './domain/usecases/managers/resche CqrsModule, ], controllers: [ItemController, BookingOrderController], - providers: [CreateBookingManager, RescheduleVerificationManager], + providers: [ + CreateBookingManager, + RescheduleVerificationManager, + RescheduleManager, + ], }) export class BookingOrderModule {} diff --git a/src/modules/transaction/transaction/data/models/transaction.model.ts b/src/modules/transaction/transaction/data/models/transaction.model.ts index 83c4416..4e47acd 100644 --- a/src/modules/transaction/transaction/data/models/transaction.model.ts +++ b/src/modules/transaction/transaction/data/models/transaction.model.ts @@ -275,6 +275,22 @@ export class TransactionModel }) refunds: RefundModel[]; + @Column('varchar', { name: 'parent_id', nullable: true }) + parent_id: string; + + @ManyToOne(() => TransactionModel, (model) => model.id, { + nullable: true, + }) + @JoinColumn({ name: 'parent_id' }) + parent_transaction: TransactionModel; + + @OneToMany(() => TransactionModel, (model) => model.parent_transaction, { + cascade: true, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + children_transactions: TransactionModel[]; + @Column('varchar', { name: 'otp_code', nullable: true }) otp_code: string; } diff --git a/src/modules/transaction/transaction/domain/entities/transaction.entity.ts b/src/modules/transaction/transaction/domain/entities/transaction.entity.ts index 8aa0b55..040c023 100644 --- a/src/modules/transaction/transaction/domain/entities/transaction.entity.ts +++ b/src/modules/transaction/transaction/domain/entities/transaction.entity.ts @@ -86,6 +86,8 @@ export interface TransactionEntity extends BaseStatusEntity { sending_qr_at: number; sending_qr_status: STATUS; + parent_id?: string; + calendar_id?: string; calendar_link?: string; diff --git a/src/services/whatsapp/whatsapp.service.ts b/src/services/whatsapp/whatsapp.service.ts index 3f95eae..4c51a4d 100644 --- a/src/services/whatsapp/whatsapp.service.ts +++ b/src/services/whatsapp/whatsapp.service.ts @@ -124,7 +124,6 @@ export class WhatsappService { } async bookingCreated(data: WhatsappBookingCreate) { - const ticketUrl = `${BOOKING_TICKET_URL}${data.id}`; const imageUrl = `${BOOKING_QR_URL}${data.id}`; const momentDate = moment(data.time); @@ -184,7 +183,82 @@ export class WhatsappService { parameters: [ { type: 'text', - text: ticketUrl, // replace with dynamic URL + text: data.id, // replace with dynamic URL + }, + ], + }, + ], + }, + }; + + const response = await this.sendMessage(payload); + if (response) + Logger.log( + `Notification register Booking for ${data.code} send to ${data.phone}`, + ); + } + + async rescheduleCreated(data: WhatsappBookingCreate) { + const imageUrl = `${BOOKING_QR_URL}${data.id}`; + + const momentDate = moment(data.time); + const fallbackValue = momentDate.locale('id').format('dddd, DD MMMM YYYY'); + // const dayOfWeek = momentDate.day(); + // const dayOfMonth = momentDate.date(); + // const year = momentDate.year(); + // const month = momentDate.month() + 1; + // const hour = momentDate.hour(); + // const minute = momentDate.minute(); + + const payload = { + messaging_product: 'whatsapp', + to: phoneNumberOnly(data.phone), // recipient's phone number + type: 'template', + template: { + name: 'reschedule_created', + language: { + code: 'id', // language code + }, + components: [ + { + type: 'header', + parameters: [ + { + type: 'image', + image: { + link: imageUrl, + }, + }, + ], + }, + { + type: 'body', + parameters: [ + { + type: 'text', + parameter_name: 'customer', + text: data.name, // replace with name variable + }, + { + type: 'text', + parameter_name: 'booking_code', + text: data.code, // replace with queue_code variable + }, + { + type: 'text', + parameter_name: 'booking_date', + text: fallbackValue, + }, + ], + }, + { + type: 'button', + sub_type: 'url', + index: '0', + parameters: [ + { + type: 'text', + text: data.id, // replace with dynamic URL }, ], }, @@ -250,7 +324,7 @@ export class WhatsappService { ); } - async bookingReschedule(data: WhatsappBookingCreate) { + async bookingRescheduleOTP(data: WhatsappBookingCreate) { const momentDate = moment(data.time); const fallbackValue = momentDate.locale('id').format('dddd, DD MMMM YYYY');