Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
5b70027
chore : 폴더 이동 ( 패키지 폴더 이름 오타 수정)
polyglot-k Jul 3, 2025
31c681d
chore: 중복된 Lombok 의존성 제거
polyglot-k Jul 3, 2025
06280a0
feat: 결제 위젯 컨트롤러 구현 ( prepare, confirm, cancel )
polyglot-k Jul 3, 2025
0c04299
feat: 결제 서비스 클래스 추가 (prepare, confirm, cancel 메서드 포함)
polyglot-k Jul 3, 2025
5228bda
feat: PaymentJpaEntity 수정 - id 타입 변경 및 생성자 매개변수 추가
polyglot-k Jul 3, 2025
3aee697
feat: PaymentAmountVO 클래스 추가 - 결제 금액 관련 필드 및 메서드 포함
polyglot-k Jul 3, 2025
755fde0
feat: PaymentMethod 열거형 수정 - 결제 수단 추가 및 이름 필드 포함
polyglot-k Jul 3, 2025
8cfbdee
feat: PaymentStatus 열거형 수정 - 결제 및 환불 상태 추가
polyglot-k Jul 3, 2025
33434a0
feat: TossPaymentConfig 및 TossPaymentErrorHandler 클래스 추가 - 결제 API 통신을…
polyglot-k Jul 3, 2025
9405823
feat: CancelTossPaymentResponse 및 ConfirmTossPaymentResponse 클래스 추가 -…
polyglot-k Jul 3, 2025
340bcaf
feat: PaymentRequest 클래스 추가 - 결제 요청 데이터 전송을 위한 DTO 구현
polyglot-k Jul 3, 2025
5b4fa53
feat: PaymentPrepareResponse 클래스 추가 - 결제 준비 응답 데이터 전송을 위한 DTO 구현
polyglot-k Jul 3, 2025
c2044a3
feat: PaymentRepository 인터페이스 추가 - 결제 관련 데이터 접근을 위한 JPA 리포지토리 구현
polyglot-k Jul 3, 2025
f48ebc8
feat: ObjectMapperConfig 클래스 추가 - Jackson ObjectMapper 빈 설정을 위한 구성 클래…
polyglot-k Jul 3, 2025
4e56f96
fix: CustomRuntimeException 필드 수정 - 상태, 메시지, 코드 필드를 final로 변경하여 불변성 보장
polyglot-k Jul 3, 2025
77cb7f5
refactor: ErrorCode 클래스에서 @AllArgsConstructor를 @RequiredArgsConstruct…
polyglot-k Jul 3, 2025
ac9fab8
feat: TossPaymentErrorResponse 및 TossPaymentPayload 클래스 추가 - 결제 오류 응답…
polyglot-k Jul 3, 2025
bfe13d0
feat: CancelPaymentRequest 클래스 추가 - 결제 취소 요청을 위한 DTO 구현
polyglot-k Jul 3, 2025
8adcab5
feat: TossPaymentClient 클래스 추가 - 결제 확인 및 취소 요청을 위한 클라이언트 구현
polyglot-k Jul 3, 2025
62a6abc
feat: BaseFakeRestOperationsStub, ConfirmFakeRestOperationsStub, Canc…
polyglot-k Jul 3, 2025
241ab6c
test: TossPaymentClientTest 클래스 추가 - 결제 확인 응답에 대한 단위 테스트 구현
polyglot-k Jul 3, 2025
0fa1666
Merge branch 'develop' of https://github.com/mosu-dev/mosu-server int…
polyglot-k Jul 3, 2025
eea0f18
chore : infra.payment 레이어에 불필요한 폴더 구성 제거
polyglot-k Jul 3, 2025
0fdd7db
feat: DiscountPolicy 열거형 추가 - 수량 기반 할인 계산 기능 구현
polyglot-k Jul 3, 2025
091c7ae
feat: DiscountCalculator 인터페이스 추가 - 할인 계산 기능 정의
polyglot-k Jul 3, 2025
bfa796c
feat: QuantityPercentageDiscountCalculator 클래스 추가 - 수량 기반 할인 계산 기능 구현
polyglot-k Jul 3, 2025
3bffc18
refactor: calculateDiscount 메서드 리팩토링 및 입력 검증 추가
polyglot-k Jul 3, 2025
a9247bd
test: QuantityPercentageDiscountCalculator 테스트 클래스 추가
polyglot-k Jul 3, 2025
65adfe9
fix: PaymentAmountVO에서 Long 타입을 Integer로 변경
polyglot-k Jul 3, 2025
4abbb0e
fix: ConfirmTossPaymentResponse에서 Long 타입을 Integer로 변경
polyglot-k Jul 3, 2025
266f535
fix: PaymentPrepareResponse에서 totalPrice 타입을 BigDecimal에서 int로 변경
polyglot-k Jul 3, 2025
dc998a2
fix: PaymentJpaEntity에서 application_quantity 필드 추가
polyglot-k Jul 3, 2025
78c306b
feat: toss 설정 추가 및 API 기본 URL 정의
polyglot-k Jul 3, 2025
e7a9502
feat: toss 설정 추가 및 API 기본 URL 정의
polyglot-k Jul 3, 2025
20168d4
fix: TossPaymentClientTest에서 금액 필드를 Long에서 int로 변경
polyglot-k Jul 3, 2025
a4f9590
feat: PaymentService에서 결제 금액 재계산 로직 추가 및 검증 강화
polyglot-k Jul 3, 2025
8ebe757
Merge branch 'develop' of https://github.com/mosu-dev/mosu-server int…
polyglot-k Jul 5, 2025
22f9482
Merge branch 'develop' into feature/mosu-33
polyglot-k Jul 5, 2025
8d4e1e3
Merge branch 'feature/mosu-33' of https://github.com/mosu-dev/mosu-se…
polyglot-k Jul 5, 2025
34111f6
feat: ProfileJpaRepository 못들고와서 추가
polyglot-k Jul 5, 2025
93938b5
feat: AOP 및 retry 의존성 추가
polyglot-k Jul 6, 2025
f333e4a
test: FixedQuantityDiscountCalculator 및 QuantityPercentageDiscountCal…
polyglot-k Jul 6, 2025
06707c2
feat: FixedQuantityDiscountCalculator 및 QuantityPercentageDiscountCal…
polyglot-k Jul 6, 2025
14497b6
feat: calculateDiscount 메서드에서 unitPrice 매개변수 제거
polyglot-k Jul 6, 2025
83d6918
feat: PreparePaymentRequest DTO 추가
polyglot-k Jul 6, 2025
38814be
feat: CancelPaymentRequest에 @NotNull 제약 조건 추가
polyglot-k Jul 6, 2025
03085ee
feat: toEntity 메서드를 toPaymentJpaEntity로 이름 변경 및 quantity 매개변수 추가
polyglot-k Jul 6, 2025
a1645d4
feat: FixedQuantityRefundAdapter 클래스 추가 및 환불 계산 로직 구현
polyglot-k Jul 6, 2025
a2b7e84
fix: CancelFakeRestOperationsStub에서 잘못된 URL 확인 메시지 수정
polyglot-k Jul 6, 2025
950d348
feat: OrderIdGenerator 클래스 추가 및 UUID 생성 로직 구현
polyglot-k Jul 6, 2025
9d9e77f
feat: PaymentEvent 클래스 추가 및 결제 상태 관리 로직 구현
polyglot-k Jul 6, 2025
345167f
feat: PaymentEventListener 클래스 추가 및 트랜잭션 이벤트 처리 로직 구현
polyglot-k Jul 6, 2025
1ad9be8
fix: 카드 결제 방법의 이름 공백 제거
polyglot-k Jul 6, 2025
93585b2
feat: RefundPolicyAdapter 인터페이스 추가 및 환불 계산 메서드 정의
polyglot-k Jul 6, 2025
c9210c6
feat: PreparePaymentRequest를 사용하여 결제 준비 메서드 수정 및 유효성 검증 추가
polyglot-k Jul 6, 2025
d14a883
feat: PaymentRequest에 유효성 검증을 추가하여 필드에 @NotNull 어노테이션 적용
polyglot-k Jul 6, 2025
84f3ec4
Merge branch 'develop' into feature/mosu-33
polyglot-k Jul 6, 2025
3a07d3f
feat: PaymentService에서 결제 준비 및 확인 메서드 수정, 유효성 검증 추가
polyglot-k Jul 6, 2025
be3b48a
feat: PaymentJpaEntity에서 quantity 필드를 생성자 및 정적 팩토리 메서드에 추가
polyglot-k Jul 6, 2025
3f74065
feat: PaymentRepository에 orderId 존재 여부 확인 메서드 추가
polyglot-k Jul 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'

testImplementation 'org.projectlombok:lombok'
testImplementation 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
Expand Down Expand Up @@ -66,6 +69,10 @@ dependencies {
implementation 'software.amazon.awssdk:core:2.30.20'
implementation 'software.amazon.awssdk:ec2'

//retry
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework.boot:spring-boot-starter-aop'

//monitoring
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package life.mosu.mosuserver.application.payment;

import java.util.UUID;
import org.springframework.stereotype.Component;

@Component
public class OrderIdGenerator {

public String generate() {
return UUID.randomUUID().toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package life.mosu.mosuserver.application.payment;

import life.mosu.mosuserver.domain.payment.PaymentStatus;

public record PaymentEvent(String applicationId, String orderId, PaymentStatus status) {

public static PaymentEvent ofSuccess(String applicationId, String orderId) {
return new PaymentEvent(applicationId, orderId, PaymentStatus.DONE);
}

public static PaymentEvent ofCancelled(String applicationId, String orderId) {
return new PaymentEvent(applicationId, orderId, PaymentStatus.CANCELLED_DONE);
}

public static PaymentEvent ofFailed(String applicationId, String orderId) {
return new PaymentEvent(applicationId, orderId, PaymentStatus.ABORTED);
}

@Override
public String toString() {
return "PaymentEvent{" +
"orderId='" + orderId + '\'' +
", status=" + status +
'}';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package life.mosu.mosuserver.application.payment;

import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
public class PaymentEventListener {

// 1. 트랜잭션 커밋 직전에 호출됨 (롤백 가능 상태)
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void beforeCommitHandler(PaymentEvent event) {
System.out.println("[BEFORE_COMMIT] 커밋 직전 처리: " + event.orderId());
// 예) 캐시 업데이트, 커밋 직전 검증 등
}

// 2. 트랜잭션 성공적으로 커밋된 후 호출 (기본값)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void afterCommitHandler(PaymentEvent event) {
System.out.println("[AFTER_COMMIT] 커밋 성공 후 처리: " + event.orderId());
// 예) 외부 API 호출, 메시지 큐 발행, 알림 전송
}

// 3. 트랜잭션이 롤백된 후 호출됨
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void afterRollbackHandler(PaymentEvent event) {
System.out.println("[AFTER_ROLLBACK] 롤백 후 처리: " + event.orderId());
// 예) 보상 트랜잭션, 로그 기록, 장애 알림
}

// 4. 트랜잭션 커밋 성공 또는 롤백 후 무조건 호출됨
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void afterCompletionHandler(PaymentEvent event) {
System.out.println("[AFTER_COMPLETION] 커밋/롤백 후 무조건 처리: " + event.orderId());
// 예) 리소스 정리, 상태 초기화 등
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package life.mosu.mosuserver.application.payment;

import life.mosu.mosuserver.domain.discount.DiscountPolicy;
import life.mosu.mosuserver.domain.payment.PaymentJpaEntity;
import life.mosu.mosuserver.domain.payment.PaymentRepository;
import life.mosu.mosuserver.infra.payment.TossPaymentClient;
import life.mosu.mosuserver.infra.payment.dto.ConfirmTossPaymentResponse;
import life.mosu.mosuserver.persistence.application.ApplicationSchoolJpaRepository;
import life.mosu.mosuserver.presentation.payment.dto.CancelPaymentRequest;
import life.mosu.mosuserver.presentation.payment.dto.PaymentPrepareResponse;
import life.mosu.mosuserver.presentation.payment.dto.PaymentRequest;
import life.mosu.mosuserver.presentation.payment.dto.PreparePaymentRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.HttpStatusCodeException;

/**
* 영속화 처리 이미 들어올 때 할인 정책을 포함해야함
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PaymentService {

private final TossPaymentClient tossPayment;
private final OrderIdGenerator orderIdGenerator;
private final PaymentRepository paymentRepository;
private final ApplicationSchoolJpaRepository applicationSchoolJpaRepository;

public PaymentPrepareResponse prepare(PreparePaymentRequest request) {
// 인원 수 체크
/**
* 인원 수 redis에 동기화 -> 인원수가 넘어가면, application 까지 rollback
*/
String uuid = orderIdGenerator.generate();
int applicationCount = request
.items()
.size();
int totalAmount = DiscountPolicy
.QUANTITY_PERCENTAGE
.calculateDiscount(applicationCount);

return PaymentPrepareResponse.of(uuid, totalAmount);
}

@Transactional
@Retryable(retryFor = {HttpStatusCodeException.class})
public void confirm(PaymentRequest request) {
// 1. 신청 조회 (가정: applicationService 또는 repository 호출)
int applicationCount = 3; // 나중에 실제 DB에서 조회
// TODO: 신청 상태 검증 (결제 가능 상태인지)

//2. 금액 재계산 및 비교
int totalAmount = DiscountPolicy.QUANTITY_PERCENTAGE.calculateDiscount(applicationCount);
if (request.amount() != totalAmount) {
throw new IllegalArgumentException("결제 금액이 올바르지 않습니다.");
}

// 3. 중복 결제 여부 확인 (예: paymentRepository.existsByApplicationIdAndStatusPaid)
// TODO: 중복 결제 로직 추가
if (paymentRepository.existsByOrderId(request.orderId())) {
throw new IllegalArgumentException("이미 존재하는 결제 건 입니다.");
}
// 4. Toss 결제 승인 요청 및 응답 검증
ConfirmTossPaymentResponse response = tossPayment.confirmPayment(request.toPayload());
PaymentJpaEntity paymentEntity = response.toPaymentJpaEntity(request.applicationId(),
applicationCount);

if (!paymentEntity.getPaymentStatus().isPaySuccess()) {
throw new IllegalArgumentException("결제가 실패하였습니다.");
}

// 5. 결제 정보 저장
paymentRepository.save(paymentEntity);
}

@Recover
public void recoverConfirm() {

}

@Transactional
public void cancel(String paymentId, CancelPaymentRequest request) {
//환불이 가능한가?
tossPayment.cancelPayment(paymentId, request);
// 환불 정책
// 영속화 해지할 필요 X
// 영속화 된 거에서 환불 상태로 변경
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package life.mosu.mosuserver.application.profile;

import life.mosu.mosuserver.domain.profile.ProfileJpaEntity;
import life.mosu.mosuserver.domain.profile.ProfileJpaRepository;
import life.mosu.mosuserver.global.exception.CustomRuntimeException;
import life.mosu.mosuserver.global.exception.ErrorCode;
import life.mosu.mosuserver.persistence.profile.ProfileJpaRepository;
import life.mosu.mosuserver.presentation.profile.dto.EditProfileRequest;
import life.mosu.mosuserver.presentation.profile.dto.ProfileDetailResponse;
import life.mosu.mosuserver.presentation.profile.dto.ProfileRequest;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package life.mosu.mosuserver.domain.discount;

public interface DiscountCalculator {

int calculateDiscount(int quantity);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package life.mosu.mosuserver.domain.discount;

public enum DiscountPolicy {
QUANTITY_PERCENTAGE(new QuantityPercentageDiscountCalculator()),
FIXED_QUANTITY(new FixedQuantityDiscountCalculator());
private final DiscountCalculator calculator;

DiscountPolicy(DiscountCalculator calculator) {
this.calculator = calculator;
}

public int calculateDiscount(int quantity) {
return calculator.calculateDiscount(quantity);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package life.mosu.mosuserver.domain.discount;

import java.util.Map;

public class FixedQuantityDiscountCalculator implements DiscountCalculator {

// 회차별 고정 할인 가격 (총 결제 금액 기준)
private static final Map<Integer, Integer> FIXED_TOTAL_PRICE = Map.of(
1, 49_000,
2, 89_000,
3, 129_000
);

@Override
public int calculateDiscount(int quantity) {
if (!FIXED_TOTAL_PRICE.containsKey(quantity)) {
throw new IllegalArgumentException("지원되지 않는 회차 수입니다. (1~3회만 가능)");
}
return FIXED_TOTAL_PRICE.get(quantity);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package life.mosu.mosuserver.domain.discount;

public class QuantityPercentageDiscountCalculator implements DiscountCalculator {

private static final int PERCENT_DIVISOR = 100;
private static final int ROUNDING_OFFSET = PERCENT_DIVISOR / 2;

private static final int DISCOUNT_RATE_ONE = 10;
private static final int DISCOUNT_RATE_TWO = 20;
private static final int DISCOUNT_RATE_THREE_OR_MORE = 30;
private static final int UNIT_PRICE = 1_000;

@Override
public int calculateDiscount(int quantity) {
validateInputs(quantity);

int discountRate = determineDiscountRate(quantity);
int totalPrice = calculateTotalPrice(quantity, UNIT_PRICE);

return calculateDiscountAmount(totalPrice, discountRate);
}

private void validateInputs(int quantity) {
if (quantity < 0) {
throw new IllegalArgumentException("수량(quantity)은 음수일 수 없습니다: " + quantity);
}
if (QuantityPercentageDiscountCalculator.UNIT_PRICE < 0) {
throw new IllegalArgumentException("단가(unitPrice)는 음수일 수 없습니다: "
+ QuantityPercentageDiscountCalculator.UNIT_PRICE);
}
}

private int determineDiscountRate(int quantity) {
return switch (quantity) {
case 1 -> DISCOUNT_RATE_ONE;
case 2 -> DISCOUNT_RATE_TWO;
default -> DISCOUNT_RATE_THREE_OR_MORE;
};
}

private int calculateTotalPrice(int quantity, int unitPrice) {
return quantity * unitPrice;
}

private int calculateDiscountAmount(int totalPrice, int discountRate) {
return (totalPrice * discountRate + ROUNDING_OFFSET) / PERCENT_DIVISOR;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package life.mosu.mosuserver.domain.payment;

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PaymentAmountVO {
@Column(name = "total_amount", nullable = false)
private Integer totalAmount;

@Column(name = "supplied_amount", nullable = false)
private Integer suppliedAmount;

@Column(name = "vat_amount", nullable = false)
private Integer vatAmount;

@Column(name = "balance_amount")
private Integer balanceAmount;

@Column(name = "tax_free_amount")
private Integer taxFreeAmount;

@Builder
public PaymentAmountVO(
Integer totalAmount,
Integer suppliedAmount,
Integer vatAmount,
Integer balanceAmount,
Integer taxFreeAmount
) {
this.totalAmount = totalAmount;
this.suppliedAmount = suppliedAmount;
this.vatAmount = vatAmount;
this.balanceAmount = balanceAmount;
this.taxFreeAmount = taxFreeAmount;
}

public static PaymentAmountVO of(
Integer totalAmount,
Integer suppliedAmount,
Integer vatAmount,
Integer balanceAmount,
Integer taxFreeAmount
) {
return PaymentAmountVO.builder()
.totalAmount(totalAmount)
.suppliedAmount(suppliedAmount)
.vatAmount(vatAmount)
.balanceAmount(balanceAmount)
.taxFreeAmount(taxFreeAmount)
.build();
}
}
Loading