diff --git a/src/coupon/coupon.ts b/src/coupon/coupon.ts new file mode 100644 index 00000000..83cd55c4 --- /dev/null +++ b/src/coupon/coupon.ts @@ -0,0 +1,72 @@ +import { BadRequestException } from '@nestjs/common'; +import { LectureCoupon } from '@prisma/client'; + +export interface ICoupon { + validateUsageCount(): void; + applyDiscount(price: number): number; + readonly percentage: number; + readonly discountPrice: number; +} + +export default class Coupon implements Coupon { + private _id: number; + private _lecturerId: number; + private _title: string; + private _percentage: number; + private _discountPrice: number; + private _maxDiscountPrice: number; + private _maxUsageCount: number; + private _usageCount: number; + private _isDisabled: boolean; + private _isStackable: boolean; + private _isPrivate: boolean; + private _startAt: Date; + private _endAt: Date; + private _createdAt: Date; + private _updatedAt: Date; + private _deletedAt: Date; + + constructor(coupon: LectureCoupon) { + Object.assign( + this, + Object.fromEntries( + Object.entries(coupon).map(([key, value]) => [`_${key}`, value]), + ), + ); + } + + validateUsageCount() { + if (this._maxUsageCount && this._usageCount >= this._maxUsageCount) { + throw new BadRequestException( + `쿠폰 사용 제한 횟수를 초과했습니다.`, + 'CouponLimit', + ); + } + } + + applyDiscount(price: number): number { + let discountedPrice = price; + + if (this._percentage > 0) { + const percentageDiscount = (price * this._percentage) / 100; + const actualDiscount = + this._maxDiscountPrice !== null + ? Math.min(percentageDiscount, this._maxDiscountPrice) + : percentageDiscount; + discountedPrice -= actualDiscount; + } + + if (this._discountPrice > 0) { + discountedPrice -= this._discountPrice; + } + + return Math.max(500, discountedPrice); + } + + get percentage(): number { + return this._percentage; + } + get discountPrice(): number { + return this._discountPrice; + } +} diff --git a/src/lecture/repositories/popular-lecture.repository.ts b/src/lecture/repositories/popular-lecture.repository.ts index 73b14d7d..21dcfb88 100644 --- a/src/lecture/repositories/popular-lecture.repository.ts +++ b/src/lecture/repositories/popular-lecture.repository.ts @@ -1,4 +1,3 @@ -import { PrismaTransaction } from '@src/common/interface/common-interface'; import { Injectable } from '@nestjs/common'; import { Lecture, Reservation } from '@prisma/client'; import { PrismaService } from '@src/prisma/prisma.service'; @@ -7,24 +6,19 @@ import { PrismaService } from '@src/prisma/prisma.service'; export class PopularLectureRepository { constructor(private readonly prismaService: PrismaService) {} - async trxReadLectureReservationCount( - trasaction: PrismaTransaction, - lectureId: number, - ): Promise { - return await trasaction.reservation.count({ + async readLectureReservationCount(lectureId: number): Promise { + return await this.prismaService.reservation.count({ where: { lectureSchedule: { lectureId } }, }); } - async trxReadLectureLikesCount( - trasaction: PrismaTransaction, - lectureId: number, - ): Promise { - return await trasaction.likedLecture.count({ where: { lectureId } }); + async readLectureLikesCount(lectureId: number): Promise { + return await this.prismaService.likedLecture.count({ + where: { lectureId }, + }); } - async trxReadLectureWithUserId( - transaction: PrismaTransaction, + async readLectureWithUserId( lectureId: number, userId?: number, ): Promise { @@ -44,17 +38,14 @@ export class PopularLectureRepository { userId ? (include['likedLecture'] = { where: { userId } }) : false; - return await transaction.lecture.findFirst({ + return await this.prismaService.lecture.findFirst({ where: { id: lectureId, isActive: true }, include, }); } - async trxReadLecture( - transaction: PrismaTransaction, - lectureId: number, - ): Promise { - return await transaction.lecture.findFirst({ + async readLecture(lectureId: number): Promise { + return await this.prismaService.lecture.findFirst({ where: { id: lectureId, isActive: true }, include: { lecturer: true, diff --git a/src/lecture/services/popular-lecture.service.ts b/src/lecture/services/popular-lecture.service.ts index 0a67e84c..6cb41f56 100644 --- a/src/lecture/services/popular-lecture.service.ts +++ b/src/lecture/services/popular-lecture.service.ts @@ -12,58 +12,48 @@ export class PopularLectureService { ) {} async readPopularLecture(userId?: number): Promise { - return await this.prismaService.$transaction( - async (trasaction: PrismaTransaction) => { - const popularScores = []; - const where = { isActive: true }; - userId - ? (where['lecturer'] = { blockedLecturer: { none: { userId } } }) - : false; + const popularScores = []; + const where = { isActive: true }; + userId + ? (where['lecturer'] = { blockedLecturer: { none: { userId } } }) + : false; - const lectures = await trasaction.lecture.findMany({ - where, - select: { id: true }, - }); + const lectures = await this.prismaService.lecture.findMany({ + where, + select: { id: true }, + }); - for (const lecture of lectures) { - const reservationCount = - await this.popularLectureRepository.trxReadLectureReservationCount( - trasaction, - lecture.id, - ); - const likesCount = - await this.popularLectureRepository.trxReadLectureLikesCount( - trasaction, - lecture.id, - ); - const popularScore = this.createPopularScore( - lecture.id, - reservationCount, - likesCount, - ); - popularScores.push(popularScore); - } + for (const lecture of lectures) { + const reservationCount = + await this.popularLectureRepository.readLectureReservationCount( + lecture.id, + ); + const likesCount = + await this.popularLectureRepository.readLectureLikesCount(lecture.id); + const popularScore = this.createPopularScore( + lecture.id, + reservationCount, + likesCount, + ); + popularScores.push(popularScore); + } - const sortedPopularScores = this.sortPopularScores(popularScores); + const sortedPopularScores = this.sortPopularScores(popularScores); - const topEightPopularScores = sortedPopularScores.slice(0, 8); + const topEightPopularScores = sortedPopularScores.slice(0, 8); - const popularLectures = []; + const popularLectures = []; - for (const popularLecture of topEightPopularScores) { - const lecture = - await this.popularLectureRepository.trxReadLectureWithUserId( - trasaction, - popularLecture.id, - userId, - ); + for (const popularLecture of topEightPopularScores) { + const lecture = await this.popularLectureRepository.readLectureWithUserId( + popularLecture.id, + userId, + ); - popularLectures.push(new LectureDto(lecture)); - } + popularLectures.push(new LectureDto(lecture)); + } - return popularLectures; - }, - ); + return popularLectures; } private createPopularScore( diff --git a/src/lecturer/repositories/popular-lecturer.repository.ts b/src/lecturer/repositories/popular-lecturer.repository.ts index 91908aed..573a718c 100644 --- a/src/lecturer/repositories/popular-lecturer.repository.ts +++ b/src/lecturer/repositories/popular-lecturer.repository.ts @@ -7,27 +7,20 @@ import { Lecturer } from '@prisma/client'; export class PopularLecturerRepository { constructor(private readonly prismaService: PrismaService) {} - async trxReadLecturerReservationCount( - trasaction: PrismaTransaction, - lecturerId: number, - ): Promise { - return await trasaction.reservation.count({ + async readLecturerReservationCount(lecturerId: number): Promise { + return await this.prismaService.reservation.count({ where: { lectureSchedule: { lecture: { lecturerId } } }, }); } - async trxReadLecturerLikesCount( - trasaction: PrismaTransaction, - lecturerId: number, - ): Promise { - return await trasaction.likedLecturer.count({ where: { lecturerId } }); + async readLecturerLikesCount(lecturerId: number): Promise { + return await this.prismaService.likedLecturer.count({ + where: { lecturerId }, + }); } - async trxReadLecturerWithLecturerId( - transaction: PrismaTransaction, - lecturerId: number, - ): Promise { - return await transaction.lecturer.findFirst({ + async readLecturerWithLecturerId(lecturerId: number): Promise { + return await this.prismaService.lecturer.findFirst({ where: { id: lecturerId }, include: { lecturerProfileImageUrl: true }, }); diff --git a/src/lecturer/services/popular-lecturer.service.ts b/src/lecturer/services/popular-lecturer.service.ts index 0c5254a1..4594a167 100644 --- a/src/lecturer/services/popular-lecturer.service.ts +++ b/src/lecturer/services/popular-lecturer.service.ts @@ -1,7 +1,6 @@ import { PrismaService } from '@src/prisma/prisma.service'; import { PopularLecturerRepository } from './../repositories/popular-lecturer.repository'; import { Injectable } from '@nestjs/common'; -import { PrismaTransaction } from '@src/common/interface/common-interface'; import { LecturerDto } from '@src/common/dtos/lecturer.dto'; @Injectable() @@ -12,63 +11,76 @@ export class PopularLecturerService { ) {} async readManyPopularLecturer(userId?: number): Promise { - return await this.prismaService.$transaction( - async (trasaction: PrismaTransaction) => { - const where = { deletedAt: null }; + const where = { deletedAt: null }; - userId ? (where['blockedLecturer'] = { none: { userId } }) : false; + userId ? (where['blockedLecturer'] = { none: { userId } }) : false; - const popularScores = []; - const lecturers = await trasaction.lecturer.findMany({ - where, - select: { id: true }, - }); + const popularScores = []; + const lecturers = await this.prismaService.lecturer.findMany({ + where, + select: { id: true }, + }); - for (const lecturer of lecturers) { - const reservationCount = - await this.popularLecturerRepository.trxReadLecturerReservationCount( - trasaction, - lecturer.id, - ); - const likesCount = - await this.popularLecturerRepository.trxReadLecturerLikesCount( - trasaction, - lecturer.id, - ); - const popularScore = { - id: lecturer.id, - reservationCount, - likesCount, - score: - Math.round((reservationCount * 0.6 + likesCount * 0.4) * 100) / - 100, - }; - popularScores.push(popularScore); - } + for (const lecturer of lecturers) { + const reservationCount = + await this.popularLecturerRepository.readLecturerReservationCount( + lecturer.id, + ); + const likesCount = + await this.popularLecturerRepository.readLecturerLikesCount( + lecturer.id, + ); + const popularScore = this.createPopularScore( + lecturer.id, + reservationCount, + likesCount, + ); - popularScores.sort((a, b) => { - if (a.score !== b.score) { - return b.score - a.score; - } else { - return b.reservationCount - a.reservationCount; - } - }); + popularScores.push(popularScore); + } - const topTenPopularScores = popularScores.slice(0, 10); - const popularLecturers = []; + const sortedPopularScores = this.sortPopularScores(popularScores); - for (const popularLecturer of topTenPopularScores) { - const lecturer = - await this.popularLecturerRepository.trxReadLecturerWithLecturerId( - trasaction, - popularLecturer.id, - ); + const topTenPopularScores = sortedPopularScores.slice(0, 10); + const popularLecturers = []; - popularLecturers.push(new LecturerDto(lecturer)); - } + for (const popularLecturer of topTenPopularScores) { + const lecturer = + await this.popularLecturerRepository.readLecturerWithLecturerId( + popularLecturer.id, + ); - return popularLecturers; - }, - ); + popularLecturers.push(new LecturerDto(lecturer)); + } + + return popularLecturers; + } + + private createPopularScore( + lecturerId: number, + reservationCount: number, + likesCount: number, + ) { + const popularScore = { + id: lecturerId, + reservationCount, + likesCount, + score: + Math.round((reservationCount * 0.6 + likesCount * 0.4) * 100) / 100, + }; + + return popularScore; + } + + private sortPopularScores(popularScores) { + popularScores.sort((a, b) => { + if (a.score !== b.score) { + return b.score - a.score; + } else { + return b.reservationCount - a.reservationCount; + } + }); + + return popularScores; } } diff --git a/src/payments/coupon/payment-coupons.ts b/src/payments/coupon/payment-coupons.ts new file mode 100644 index 00000000..23cb7677 --- /dev/null +++ b/src/payments/coupon/payment-coupons.ts @@ -0,0 +1,66 @@ +import { BadRequestException } from '@nestjs/common'; +import { ICoupon } from '@src/coupon/coupon'; + +export default class PaymentCoupons { + private readonly _coupon: ICoupon | null; + private readonly _stackableCoupon: ICoupon | null; + + constructor(coupon: ICoupon | null, stackableCoupon: ICoupon | null) { + this._coupon = coupon; + this._stackableCoupon = stackableCoupon; + + this.validateCoupons(); + this.validateNoDuplicatePercentage(); + } + + private validateCoupons() { + if (this._coupon) { + this._coupon.validateUsageCount(); + } + if (this._stackableCoupon) { + this._stackableCoupon.validateUsageCount(); + } + } + + private validateNoDuplicatePercentage() { + if ( + this._coupon && + this._stackableCoupon && + this._coupon.percentage > 0 && + this._stackableCoupon.percentage > 0 + ) { + throw new BadRequestException( + `할인율은 중복적용이 불가능합니다.`, + 'DuplicatePercentageDiscount', + ); + } + } + + applyDiscount(initialPrice: number): number { + const firstCoupon = + this._coupon?.percentage > 0 ? this._coupon : this._stackableCoupon; + const secondCoupon = + firstCoupon === this._coupon ? this._stackableCoupon : this._coupon; + + let price = initialPrice; + if (firstCoupon) { + price = firstCoupon.applyDiscount(price); + } + if (secondCoupon) { + price = secondCoupon.applyDiscount(price); + } + + return price; + } + + compareCouponAppliedPrice(initialPrice: number, clientPrice: number) { + const finalPrice = this.applyDiscount(initialPrice); + + if (finalPrice !== clientPrice) { + throw new BadRequestException( + `결제 금액이 일치하지 않습니다.`, + 'PaymentAmountMismatch', + ); + } + } +} diff --git a/src/payments/repository/payments.repository.ts b/src/payments/repository/payments.repository.ts index cf9f8035..b20f156d 100644 --- a/src/payments/repository/payments.repository.ts +++ b/src/payments/repository/payments.repository.ts @@ -59,10 +59,9 @@ export class PaymentsRepository { lectureId: number, couponId: number, isStackable: boolean, + currentDate: Date = new Date(), ): Promise { try { - const currentDate = generateCurrentTime(); - return await this.prismaService.lectureCoupon.findFirst({ where: { id: couponId, diff --git a/test/unit/coupon/coupon.spec.ts b/test/unit/coupon/coupon.spec.ts new file mode 100644 index 00000000..5a69f622 --- /dev/null +++ b/test/unit/coupon/coupon.spec.ts @@ -0,0 +1,82 @@ +import { LectureCoupon } from '@prisma/client'; +import Coupon from '@src/coupon/coupon'; + +const generateCoupon = (override?: Partial) => { + return new Coupon({ + id: 1, + lecturerId: 1, + title: '일반 쿠폰', + percentage: null, + discountPrice: null, + maxDiscountPrice: null, + maxUsageCount: null, + usageCount: 0, + isDisabled: false, + isStackable: false, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + isPrivate: false, + startAt: new Date(), + endAt: new Date(), + ...override, + }); +}; + +describe('Coupon', () => { + it('정상적으로 생성되어야 한다.', () => { + const coupon = generateCoupon(); + + expect(coupon).toBeDefined(); + }); + + describe('validateUsageCount', () => { + it('사용횟수가 초과되지 않았다면 오류가 발생하지 않아야 한다.', () => { + const coupon = generateCoupon({ usageCount: 0, maxUsageCount: 10 }); + expect(() => coupon.validateUsageCount()).not.toThrow(); + }); + + it('사용횟수가 초과되었다면 오류가 발생해야 한다.', () => { + const coupon = generateCoupon({ usageCount: 10, maxUsageCount: 10 }); + expect(() => coupon.validateUsageCount()).toThrow(); + }); + }); + + describe('applyDiscount', () => { + it('할인율이 적용되어야한다.', () => { + const price = 10000; + const coupon = generateCoupon({ id: 1, percentage: 30 }); + const expectedPrice = 7000; + + expect(coupon.applyDiscount(price)).toBe(expectedPrice); + }); + + it('할인율이 maxDiscountPrice를 초과하면 maxDiscountPrice 가 적용되어야한다.', () => { + const price = 10000; + const coupon = generateCoupon({ + id: 1, + percentage: 50, + maxDiscountPrice: 1000, + }); + const expectedPrice = 9000; + + expect(coupon.applyDiscount(price)).toBe(expectedPrice); + }); + + it('할인금액이 적용되어야 한다.', () => { + const price = 10000; + const coupon = generateCoupon({ id: 1, discountPrice: 1000 }); + const expectedPrice = 9000; + + expect(coupon.applyDiscount(price)).toBe(expectedPrice); + }); + + it('할인된 금액이 지정된 최소금액보다 낮으면 최소금액이 적용되어야 한다.', () => { + const price = 10000; + const coupon = generateCoupon({ id: 1, discountPrice: 10000 }); + const expectedPrice = 500; + + expect(coupon.applyDiscount(price)).toBe(expectedPrice); + }); + }); +}); diff --git a/test/unit/payment/newFile.ts b/test/unit/payment/newFile.ts new file mode 100644 index 00000000..462607cb --- /dev/null +++ b/test/unit/payment/newFile.ts @@ -0,0 +1,31 @@ +import PaymentCoupons from '@src/payments/coupon/payment-coupons'; +import { generateCoupon } from './payment-coupons.spec'; + +describe('PaymentCoupons', () => { + it('정상적으로 생성되어야 한다.', () => { + const coupon = generateCoupon({ id: 1, percentage: 10 }); + const stackableCoupon = generateCoupon({ + id: 2, + isStackable: true, + discountPrice: 1000, + }); + + expect(new PaymentCoupons(coupon, stackableCoupon)).toBeDefined(); + }); + + it('할인 방식이 모두 퍼센트인 경우 오류가 발생해야 한다.', () => { + const coupon = generateCoupon({ id: 1, percentage: 10 }); + const stackableCoupon = generateCoupon({ + id: 2, + isStackable: true, + percentage: 10, + }); + + expect(() => new PaymentCoupons(coupon, stackableCoupon)).toThrow( + BadRequestException( + `할인율은 중복적용이 불가능합니다.`, + 'DuplicateDiscount', + ), + ); + }); +}); diff --git a/test/unit/payment/payment-coupons.spec.ts b/test/unit/payment/payment-coupons.spec.ts new file mode 100644 index 00000000..35d07a01 --- /dev/null +++ b/test/unit/payment/payment-coupons.spec.ts @@ -0,0 +1,60 @@ +import { BadRequestException } from '@nestjs/common'; +import { LectureCoupon } from '@prisma/client'; +import Coupon from '@src/coupon/coupon'; +import PaymentCoupons from '@src/payments/coupon/payment-coupons'; + +const generateCoupon = (override?: Partial) => { + return new Coupon({ + id: 1, + lecturerId: 1, + title: '일반 쿠폰', + percentage: null, + discountPrice: null, + maxDiscountPrice: null, + maxUsageCount: null, + usageCount: 0, + isDisabled: false, + isStackable: false, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + isPrivate: false, + startAt: new Date(), + endAt: new Date(), + ...override, + }); +}; + +describe('PaymentCoupons', () => { + it('정상적으로 생성되어야 한다.', () => { + const coupon = generateCoupon({ id: 1, percentage: 10 }); + const stackableCoupon = null; + expect(new PaymentCoupons(coupon, stackableCoupon)).toBeDefined(); + }); + + it('할인율은 중복 적용이 불가능해야 한다.', () => { + const coupon = generateCoupon({ id: 1, percentage: 10 }); + const stackableCoupon = generateCoupon({ + id: 2, + isStackable: true, + percentage: 10, + }); + + expect(() => new PaymentCoupons(coupon, stackableCoupon)).toThrow( + new BadRequestException( + `할인율은 중복적용이 불가능합니다.`, + 'DuplicateDiscount', + ), + ); + }); + + describe('applyDiscount', () => { + it('할인율이 적용되어야 한다.', () => { + const coupon = generateCoupon({ id: 1, percentage: 10 }); + const stackableCoupon = null; + const paymentCoupons = new PaymentCoupons(coupon, stackableCoupon); + + expect(paymentCoupons.applyDiscount(1000)).toBe(900); + }); + }); +});