Compare commits

...

7 Commits

Author SHA1 Message Date
shancheas 94e769795a fix: add version to booking api 2025-05-27 13:26:41 +07:00
shancheas 968697ee17 feat: add check period to public url 2025-05-27 11:51:54 +07:00
shancheas 2dd0bd45a8 feat: booking online OTP and item 2025-05-27 11:11:08 +07:00
shancheas 8afbe33c01 refactor: replace CouchService with CouchModule in transaction modules 2025-05-10 06:31:57 +07:00
shancheas 928e2e7648 feat: enhance transaction calculations in CouchService and SalesPriceFormulaDataService 2025-05-09 17:56:30 +07:00
shancheas 94baf956dd feat: add env to active skip transaction feature 2025-05-09 06:44:17 +07:00
shancheas e92f325807 fix: make transaction setting public 2025-05-02 11:25:33 +07:00
20 changed files with 382 additions and 15 deletions

View File

@ -96,7 +96,9 @@ import {
import { ItemQueueModule } from './modules/item-related/item-queue/item-queue.module';
import { ItemQueueModel } from './modules/item-related/item-queue/data/models/item-queue.model';
import { QueueBucketModel } from './modules/queue/data/models/queue-bucket.model';
import { VerificationModel } from './modules/booking-online/authentication/data/models/verification.model';
import { BookingOnlineAuthModule } from './modules/booking-online/authentication/auth.module';
import { BookingOrderModule } from './modules/booking-online/order/order.module';
@Module({
imports: [
ApmModule.register(),
@ -157,6 +159,9 @@ import { QueueBucketModel } from './modules/queue/data/models/queue-bucket.model
QueueItemModel,
QueueModel,
QueueBucketModel,
// Booking Online
VerificationModel,
],
synchronize: false,
}),
@ -218,6 +223,9 @@ import { QueueBucketModel } from './modules/queue/data/models/queue-bucket.model
GateScanModule,
QueueModule,
BookingOnlineAuthModule,
BookingOrderModule,
],
controllers: [],
providers: [

View File

@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class BookingAuthentication1748313715598 implements MigrationInterface {
name = 'BookingAuthentication1748313715598';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "booking_verification" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "phone_number" character varying NOT NULL, "code" character varying, "tried" integer NOT NULL DEFAULT '0', "created_at" bigint NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000, "updated_at" bigint NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000, CONSTRAINT "PK_046e9288c7dd05c7259275d9fc0" PRIMARY KEY ("id"))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "booking_verification"`);
}
}

View File

@ -0,0 +1,25 @@
import { CONNECTION_NAME } from 'src/core/strings/constants/base.constants';
import { ConfigModule } from '@nestjs/config';
import { Module } from '@nestjs/common';
import { VerificationModel } from './data/models/verification.model';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingAuthenticationController } from './infrastructure/controllers/booking-authentication.controller';
import { VerificationService } from './data/services/verification.service';
import { JwtModule } from '@nestjs/jwt';
import { JWT_EXPIRED } from 'src/core/sessions/constants';
import { JWT_SECRET } from 'src/core/sessions/constants';
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forFeature([VerificationModel], CONNECTION_NAME.DEFAULT),
JwtModule.register({
secret: JWT_SECRET,
signOptions: { expiresIn: JWT_EXPIRED },
}),
],
controllers: [BookingAuthenticationController],
providers: [VerificationService],
})
export class BookingOnlineAuthModule {}

View File

@ -0,0 +1,29 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { BookingVerification } from '../../domain/entities/booking-verification.entity';
@Entity('booking_verification')
export class VerificationModel implements BookingVerification {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column()
phone_number: string;
@Column({ nullable: true })
code?: string;
@Column({ default: 0 })
tried: number;
@Column({ type: 'bigint', default: () => 'EXTRACT(EPOCH FROM NOW()) * 1000' })
created_at: number;
@Column({
type: 'bigint',
default: () => 'EXTRACT(EPOCH FROM NOW()) * 1000',
onUpdate: 'EXTRACT(EPOCH FROM NOW()) * 1000',
})
updated_at: number;
}

View File

@ -0,0 +1,95 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { VerificationModel } from '../models/verification.model';
import { BookingVerification } from '../../domain/entities/booking-verification.entity';
import { UnprocessableEntityException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
export class VerificationService {
constructor(
@InjectRepository(VerificationModel)
private readonly verificationRepository: Repository<VerificationModel>,
private readonly jwtService: JwtService,
) {}
maxAttempts = 3;
expiredTime = 5 * 60 * 1000;
expiredTimeRegister = 1 * 60 * 1000;
async generateToken(payload: BookingVerification) {
return this.jwtService.sign({
phone_number: payload.phone_number,
name: payload.name,
created_at: payload.created_at,
});
}
async register(data: BookingVerification) {
const currentTime = Math.floor(Date.now()); // current time in seconds
if (
data.created_at &&
currentTime - data.created_at > this.expiredTimeRegister
) {
throw new UnprocessableEntityException('Please try again in 1 minute');
}
// Generate a 4 digit OTP code
data.code = Math.floor(1000 + Math.random() * 9000).toString();
data.tried = 0;
data.updated_at = currentTime;
let verification = await this.verificationRepository.findOne({
where: { phone_number: data.phone_number },
});
if (verification) {
// Update existing record
verification = this.verificationRepository.merge(verification, data);
} else {
// Create new record
verification = this.verificationRepository.create(data);
}
return this.verificationRepository.save(verification);
}
async findByPhoneNumber(phoneNumber: string) {
return this.verificationRepository.findOne({
where: { phone_number: phoneNumber },
});
}
async verify(data: BookingVerification): Promise<BookingVerification> {
const verification = await this.findByPhoneNumber(data.phone_number);
if (!verification) {
throw new UnprocessableEntityException('Phone number not found');
}
if (verification.tried >= this.maxAttempts) {
throw new UnprocessableEntityException(
'Too many attempts, please resend OTP Code',
);
}
if (verification.code != data.code) {
verification.tried++;
await this.verificationRepository.save(verification);
throw new UnprocessableEntityException('Invalid verification code');
}
const currentTime = Math.floor(Date.now());
if (
verification.updated_at &&
currentTime - verification.updated_at > this.expiredTime
) {
throw new UnprocessableEntityException('Verification code expired');
}
return verification;
}
async update(id: string, data: BookingVerification) {
return this.verificationRepository.update(id, data);
}
async delete(id: string) {
return this.verificationRepository.delete(id);
}
}

View File

@ -0,0 +1,9 @@
export interface BookingVerification {
id: string;
name: string;
phone_number: string;
code?: string;
tried?: number;
created_at?: number;
updated_at?: number;
}

View File

@ -0,0 +1,43 @@
import { Controller, Post, Body, Get, Param } from '@nestjs/common';
import {
BookingVerificationDto,
VerificationCodeDto,
} from '../dto/booking-verification.dto';
import { VerificationService } from '../../data/services/verification.service';
import { Public } from 'src/core/guards/domain/decorators/unprotected.guard';
import { ApiTags } from '@nestjs/swagger';
@ApiTags('Booking Authentication')
@Public()
@Controller('v1/booking-authentication')
export class BookingAuthenticationController {
constructor(
private readonly bookingAuthenticationService: VerificationService,
) {}
@Post('verify')
async verify(@Body() body: VerificationCodeDto) {
const verification = await this.bookingAuthenticationService.verify(body);
const token = await this.bookingAuthenticationService.generateToken(
verification,
);
return {
message: `Verification successful for ${verification.phone_number}`,
token,
};
}
@Post('register')
async register(@Body() body: BookingVerificationDto) {
const verification = await this.bookingAuthenticationService.register(body);
return {
message: `Verification code sent to ${verification.phone_number}`,
};
}
@Get('get-by-phone/:phone_number')
async getByPhoneNumber(@Param('phone_number') phone_number: string) {
return this.bookingAuthenticationService.findByPhoneNumber(phone_number);
}
}

View File

@ -0,0 +1,67 @@
import { BookingVerification } from '../../domain/entities/booking-verification.entity';
import { IsString, IsNotEmpty, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class BookingVerificationDto implements BookingVerification {
id: string;
@ApiProperty({
type: String,
required: true,
example: 'John Doe',
description: 'Name of the person booking',
})
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({
type: String,
required: true,
example: '628123456789',
description: 'Phone number containing only numeric characters',
})
@IsString()
@IsNotEmpty()
@Matches(/^\d+$/, {
message: 'phone_number must contain only numeric characters',
})
phone_number: string;
}
export class VerificationCodeDto implements BookingVerification {
id: string;
@ApiProperty({
type: String,
required: true,
example: 'John Doe',
description: 'Name of the person booking',
})
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({
type: String,
required: true,
example: '628123456789',
description: 'Phone number containing only numeric characters',
})
@IsString()
@IsNotEmpty()
@Matches(/^\d+$/, {
message: 'phone_number must contain only numeric characters',
})
phone_number: string;
@ApiProperty({
type: String,
required: true,
example: '1234',
description: 'Verification code',
})
@IsString()
@IsNotEmpty()
code?: string;
}

View File

@ -0,0 +1,35 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Public } from 'src/core/guards';
import { PaginationResponse } from 'src/core/response/domain/ok-response.interface';
import { TABLE_NAME } from 'src/core/strings/constants/table.constants';
import { ItemReadService } from 'src/modules/item-related/item/data/services/item-read.service';
import { ItemEntity } from 'src/modules/item-related/item/domain/entities/item.entity';
import { IndexItemManager } from 'src/modules/item-related/item/domain/usecases/managers/index-item.manager';
import { FilterItemDto } from 'src/modules/item-related/item/infrastructure/dto/filter-item.dto';
@ApiTags('Booking Item')
@Controller('v1/booking-item')
@Public(true)
export class ItemController {
constructor(
private indexManager: IndexItemManager,
private serviceData: ItemReadService,
) {}
@Get()
async index(
@Query() params: FilterItemDto,
): Promise<PaginationResponse<ItemEntity>> {
try {
params.show_to_booking = true;
this.indexManager.setFilterParam(params);
this.indexManager.setService(this.serviceData, TABLE_NAME.ITEM);
await this.indexManager.execute();
return this.indexManager.getResult();
} catch (error) {
console.log(error);
throw error;
}
}
}

View File

@ -0,0 +1,18 @@
import { CONNECTION_NAME } from 'src/core/strings/constants/base.constants';
import { ConfigModule } from '@nestjs/config';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ItemModel } from 'src/modules/item-related/item/data/models/item.model';
import { ItemModule } from 'src/modules/item-related/item/item.module';
import { ItemController } from './infrastructure/item.controller';
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forFeature([ItemModel], CONNECTION_NAME.DEFAULT),
ItemModule,
],
controllers: [ItemController],
providers: [],
})
export class BookingOrderModule {}

View File

@ -100,7 +100,7 @@ export class CouchService {
public async totalTodayTransactions(database = 'transaction') {
try {
const nano = this.nanoInstance;
const db = nano.use(database);
const db = nano.use<any>(database);
// Get today's start timestamp (midnight)
const today = new Date();
@ -116,10 +116,14 @@ export class CouchService {
const result = await db.find({
selector: selector,
fields: ['_id'],
fields: ['_id', 'payment_total_pay'],
limit: 10000,
});
return result.docs.length;
return result.docs.reduce(
(sum, doc) => sum + (doc.payment_total_pay || 0),
0,
);
} catch (error) {
console.log(error);
apm.captureError(error);

View File

@ -98,6 +98,10 @@ export class IndexItemManager extends BaseIndexManager<ItemEntity> {
queryBuilder.andWhere(`${this.tableName}.tenant_id Is Null`);
}
if (this.filterParam.show_to_booking) {
queryBuilder.andWhere(`${this.tableName}.show_to_booking = true`);
}
return queryBuilder;
}
}

View File

@ -40,4 +40,6 @@ export class FilterItemDto extends BaseFilterDto implements FilterItemEntity {
})
@Transform((body) => body.value == 'true')
all_item: boolean;
show_to_booking: boolean;
}

View File

@ -26,6 +26,7 @@ export class SeasonPeriodReadController {
return await this.orchestrator.index(params);
}
@Public(true)
@Get('current-period')
async currentPeriod(
@Query() params: FilterCurrentSeasonDto,

View File

@ -14,12 +14,13 @@ import {
TransactionSettingModel,
} from '../models/sales-price-formula.model';
import { CONNECTION_NAME } from 'src/core/strings/constants/base.constants';
import { MoreThan, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { FormulaType } from '../../constants';
import { TaxModel } from 'src/modules/transaction/tax/data/models/tax.model';
import { ItemModel } from 'src/modules/item-related/item/data/models/item.model';
import { TransactionModel } from 'src/modules/transaction/transaction/data/models/transaction.model';
import { CouchService } from 'src/modules/configuration/couch/data/services/couch.service';
import { TransactionType } from 'src/modules/transaction/transaction/constants';
@Injectable()
export class SalesPriceFormulaDataService extends BaseDataService<SalesPriceFormulaEntity> {
@ -67,11 +68,17 @@ export class SalesPriceFormulaDataService extends BaseDataService<SalesPriceForm
const todayTimestamp = today.getTime();
const totalTransactions = await this.transaction.count({
where: {
created_at: MoreThan(todayTimestamp),
},
});
const totalTransactions = parseInt(
await this.transaction
.createQueryBuilder('transaction')
.select('SUM(transaction.payment_total_pay)', 'sum')
.where('transaction.created_at > :timestamp', {
timestamp: todayTimestamp,
})
.andWhere('transaction.type = :type', { type: TransactionType.COUNTER })
.getRawOne()
.then((result) => result.sum || 0),
);
const couchTransaction = await this.couchService.totalTodayTransactions();

View File

@ -25,6 +25,7 @@ export class SalesPriceFormulaDataController {
return await this.orchestrator.update(data);
}
@Public(true)
@Put()
async updateTransactionSetting(
@Body() data: TransactionSettingDto,

View File

@ -16,6 +16,7 @@ export class SalesPriceFormulaReadController {
return await this.orchestrator.detail();
}
@Public(true)
@Get('detail')
async getTransactionSetting(): Promise<any> {
return await this.orchestrator.getTransactionSetting();

View File

@ -23,7 +23,7 @@ import { TaxModel } from '../tax/data/models/tax.model';
import { ItemModel } from 'src/modules/item-related/item/data/models/item.model';
import { UpdateTransactionSettingManager } from './domain/usecases/managers/update-transaction-setting.manager';
import { TransactionModel } from '../transaction/data/models/transaction.model';
import { CouchService } from 'src/modules/configuration/couch/data/services/couch.service';
import { CouchModule } from 'src/modules/configuration/couch/couch.module';
@Global()
@Module({
@ -40,6 +40,7 @@ import { CouchService } from 'src/modules/configuration/couch/data/services/couc
CONNECTION_NAME.DEFAULT,
),
CqrsModule,
CouchModule,
],
controllers: [
SalesPriceFormulaDataController,
@ -57,7 +58,6 @@ import { CouchService } from 'src/modules/configuration/couch/data/services/couc
SalesPriceFormulaDataOrchestrator,
SalesPriceFormulaReadOrchestrator,
CouchService,
],
exports: [SalesPriceFormulaDataService, SalesPriceFormulaReadService],
})

View File

@ -31,6 +31,9 @@ export class PosTransactionHandler implements IEventHandler<ChangeDocEvent> {
) {}
async handle(event: ChangeDocEvent) {
const envSkipTransaction = process.env.SKIP_TRANSACTION_FEATURE ?? 'false';
const activeSkipTransaction = envSkipTransaction == 'true';
const apmTransactions = apm.startTransaction(
`ChangeDocEvent ${event?.data?.database}`,
'handler',
@ -107,6 +110,7 @@ export class PosTransactionHandler implements IEventHandler<ChangeDocEvent> {
// Check if this transaction should be sent to the "black hole" (not saved)
// This is only applicable for SETTLED transactions
const shouldSkipSaving =
activeSkipTransaction &&
data.status === STATUS.SETTLED &&
(await this.formulaService.sentToBlackHole());

View File

@ -46,7 +46,7 @@ import { PaymentMethodModel } from '../payment-method/data/models/payment-method
import { TransactionDemographyModel } from './data/models/transaction-demography.model';
import { PriceCalculator } from './domain/usecases/calculator/price.calculator';
import { ItemModel } from 'src/modules/item-related/item/data/models/item.model';
import { CouchService } from 'src/modules/configuration/couch/data/services/couch.service';
import { CouchModule } from 'src/modules/configuration/couch/couch.module';
@Module({
exports: [TransactionReadService],
@ -70,6 +70,7 @@ import { CouchService } from 'src/modules/configuration/couch/data/services/couc
CONNECTION_NAME.DEFAULT,
),
CqrsModule,
CouchModule,
],
controllers: [TransactionDataController, TransactionReadController],
providers: [
@ -101,8 +102,6 @@ import { CouchService } from 'src/modules/configuration/couch/data/services/couc
TransactionDataOrchestrator,
TransactionReadOrchestrator,
CouchService,
],
})
export class TransactionModule {}